重置项目

This commit is contained in:
2026-07-04 15:59:20 +08:00
parent 374e587f2d
commit 648a8b7f1c
224 changed files with 19700 additions and 9547 deletions
+2 -2
View File
@@ -1,11 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#8B5CF6" />
<title>A股工作台</title>
<title>TickFlow Stock Panel · Quant Terminal</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
-3041
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "stock-panel-frontend",
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.1.44",
"version": "0.1.70",
"type": "module",
"scripts": {
"dev": "vite",
@@ -1,46 +0,0 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
function getStoredToken(): string | null {
try { return localStorage.getItem('access_token') } catch { return null }
}
export function AccessGuard({ children, requireAdmin = false }: { children: React.ReactNode; requireAdmin?: boolean }) {
const location = useLocation()
const token = getStoredToken()
const { data: status, isLoading } = useQuery({
queryKey: ['access-auth-status', token],
queryFn: () => api.accessAuthStatus(),
// 即使 token 为空也查询一次,用于确认服务端是否启用了门控
enabled: true,
staleTime: 5 * 60 * 1000,
})
if (isLoading) {
return (
<div className="h-screen w-full flex items-center justify-center bg-base text-foreground">
<Loader2 className="h-6 w-6 animate-spin text-accent" />
</div>
)
}
// 未启用门控,直接放行
if (!status?.enabled) {
return <>{children}</>
}
// 未验证,跳转到登录页
if (!status.verified) {
return <Navigate to="/verify" state={{ from: location.pathname }} replace />
}
// 需要管理员权限但当前非管理员
if (requireAdmin && status.role !== 'admin') {
return <Navigate to="/" replace />
}
return <>{children}</>
}
@@ -1,33 +0,0 @@
import { Navigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
function getStoredRole(): string | null {
try { return localStorage.getItem('access_role') } catch { return null }
}
export function AdminGuard({ children }: { children: React.ReactNode }) {
const { data: status, isLoading } = useQuery({
queryKey: ['access-auth-status'],
queryFn: () => api.accessAuthStatus(),
enabled: true,
staleTime: 5 * 60 * 1000,
})
if (isLoading) {
return (
<div className="h-screen w-full flex items-center justify-center bg-base text-foreground">
<Loader2 className="h-6 w-6 animate-spin text-accent" />
</div>
)
}
// 未启用门控或本地角色为 admin,均放行
const localRole = getStoredRole()
if (!status?.enabled || localRole === 'admin' || status.role === 'admin') {
return <>{children}</>
}
return <Navigate to="/" replace />
}
+2 -2
View File
@@ -161,10 +161,10 @@ export function AlertToastContainer() {
{ev.price != null && <span className="text-[10px] font-mono text-muted shrink-0">{fmtPrice(ev.price)}</span>}
</div>
) : (
<div className="mt-1 flex items-center gap-2 pl-0.5">
<div className="mt-1 flex items-center gap-1.5 pl-0.5">
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
{/* message 已含「条件摘要 · 现价 · 涨跌幅」(后端生成), 直接展示避免重复 */}
{ev.message && <span className="text-[11px] text-foreground/70 truncate flex-1">{ev.message}</span>}
{ev.price != null && <span className="text-[10px] font-mono text-muted shrink-0">{fmtPrice(ev.price)}</span>}
</div>
)}
</motion.div>
@@ -1,7 +1,6 @@
import { useEffect, useRef, useCallback, useMemo } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import { useChartTheme, type ChartTheme } from '@/lib/chartTheme'
export interface OHLC {
date: string
@@ -294,25 +293,19 @@ interface Props {
activeIndicators?: string[]
}
function getTHEME(ct: ChartTheme) {
return {
bull: '#C74040',
bear: '#2D9B65',
bullAlpha: 'rgba(240,68,56,0.7)',
bearAlpha: 'rgba(18,183,106,0.7)',
ma5: '#A1A1AA',
ma10: '#3B82F6',
ma20: '#F97316',
ma60: '#8B5CF6',
text: ct.text,
grid: ct.splitLine,
border: ct.axisLine,
bg: 'transparent',
tooltipBg: ct.tooltipBg,
tooltipBorder: ct.tooltipBorder,
tooltipText: ct.tooltipText,
crosshair: ct.crosshair,
}
const THEME = {
bull: '#C74040',
bear: '#2D9B65',
bullAlpha: 'rgba(240,68,56,0.7)',
bearAlpha: 'rgba(18,183,106,0.7)',
ma5: '#A1A1AA',
ma10: '#3B82F6',
ma20: '#F97316',
ma60: '#8B5CF6',
text: '#A1A1AA',
grid: 'rgba(255,255,255,0.04)',
border: '#27272A',
bg: 'transparent',
}
/** 可见蜡烛超过此数量时,涨停/炸板标签切换为小圆点。 */
@@ -328,7 +321,6 @@ function buildSubInfoGraphics(
infoIdx: number,
activeIndicators: string[],
subStartTop: number,
theme: ReturnType<typeof getTHEME>,
): any[] {
const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null
const graphics: any[] = []
@@ -357,7 +349,7 @@ function buildSubInfoGraphics(
id: `sub-sep-${key}`,
type: 'line',
shape: { x1: 0, y1: curTop, x2: 2000, y2: curTop },
style: { stroke: theme.grid, lineWidth: 1 },
style: { stroke: 'rgba(255,255,255,0.08)', lineWidth: 1 },
silent: true, z: 0,
})
graphics.push({
@@ -422,7 +414,6 @@ function buildOption(
containerHeight: number,
infoIdx: number,
linkedPrice: number | null | undefined,
theme: ReturnType<typeof getTHEME>,
): EChartsOption {
const candleData = data.map(d => [d.open, d.close, d.low, d.high])
@@ -438,7 +429,7 @@ function buildOption(
const isSell = m.kind === 'sell'
if (m.above) {
const dotColor = m.color ?? (isBuy ? '#FACC15' : theme.text)
const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text)
if (compact) {
markPointData.push({
name: m.date, coord: [m.date, d.high],
@@ -466,11 +457,11 @@ function buildOption(
symbol: 'arrow', symbolSize: 12,
symbolRotate: isBuy ? 0 : 180,
symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'],
itemStyle: { color: isBuy ? theme.bull : isSell ? theme.bear : theme.text },
itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : THEME.text },
label: {
show: !!m.label, formatter: m.label ?? '',
position: isBuy ? 'bottom' : 'top', distance: 8,
color: theme.text, fontSize: 10,
color: THEME.text, fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
},
})
@@ -506,8 +497,8 @@ function buildOption(
grids.push({ left, right, top: topPad, height: candleAvail })
xAxes.push({
type: 'category', data: dates, boundaryGap: true,
axisLine: { lineStyle: { color: theme.border } },
axisLabel: { color: theme.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
axisLine: { lineStyle: { color: THEME.border } },
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
axisTick: { show: false },
splitLine: { show: false },
})
@@ -517,8 +508,8 @@ function buildOption(
boundaryGap: [0.03, 0.03],
splitArea: { show: false },
axisLine: { show: false }, axisTick: { show: false },
splitLine: { lineStyle: { color: theme.grid } },
axisLabel: { color: theme.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
splitLine: { lineStyle: { color: THEME.grid } },
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
})
xAxisIndices.push(0)
@@ -533,9 +524,9 @@ function buildOption(
show: !!r.label,
position: 'insideTop',
distance: 8,
color: theme.tooltipText,
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
color: '#DBEAFE',
backgroundColor: 'rgba(15,23,42,0.72)',
borderColor: 'rgba(59,130,246,0.35)',
borderWidth: 1,
borderRadius: 4,
padding: [2, 6],
@@ -550,7 +541,7 @@ function buildOption(
.filter(line => Number.isFinite(line.value))
.map(line => {
const lineStyle = {
color: line.color ?? theme.text,
color: line.color ?? THEME.text,
type: 'dashed' as const,
width: 1,
opacity: 0.92,
@@ -559,8 +550,8 @@ function buildOption(
show: !!line.label,
formatter: line.label ?? '',
position: 'insideEndTop' as const,
color: line.color ?? theme.text,
backgroundColor: theme.tooltipBg,
color: line.color ?? THEME.text,
backgroundColor: 'rgba(15,23,42,0.72)',
borderRadius: 4,
padding: [2, 6],
fontSize: 10,
@@ -586,7 +577,7 @@ function buildOption(
color: '#3B82F6',
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
backgroundColor: theme.tooltipBg,
backgroundColor: 'rgba(24,24,27,0.85)',
borderColor: '#3B82F6',
borderWidth: 1,
padding: [1, 4],
@@ -600,8 +591,8 @@ function buildOption(
name: 'K', type: 'candlestick', data: candleData,
animation: false,
itemStyle: {
color: theme.bull, color0: theme.bear,
borderColor: theme.bull, borderColor0: theme.bear,
color: THEME.bull, color0: THEME.bear,
borderColor: THEME.bull, borderColor0: THEME.bear,
cursor: 'pointer',
},
markPoint: markPointData.length > 0 ? { data: markPointData, animation: false } : undefined,
@@ -617,10 +608,10 @@ function buildOption(
silent: true,
lineStyle: { width: 1, color }, itemStyle: { color },
})
series.push(maLine('ma5', theme.ma5, 'MA5'))
series.push(maLine('ma10', theme.ma10, 'MA10'))
series.push(maLine('ma20', theme.ma20, 'MA20'))
series.push(maLine('ma60', theme.ma60, 'MA60'))
series.push(maLine('ma5', THEME.ma5, 'MA5'))
series.push(maLine('ma10', THEME.ma10, 'MA10'))
series.push(maLine('ma20', THEME.ma20, 'MA20'))
series.push(maLine('ma60', THEME.ma60, 'MA60'))
}
// BOLL 布林带 — 需在 activeIndicators 中激活
@@ -651,7 +642,7 @@ function buildOption(
top: chartTop,
height: def.height,
show: true,
borderColor: theme.grid,
borderColor: 'rgba(255,255,255,0.06)',
borderWidth: 1,
})
@@ -669,9 +660,9 @@ function buildOption(
gridIndex: gridIdx,
splitNumber: 2,
axisLine: { show: false }, axisTick: { show: false },
splitLine: { lineStyle: { color: theme.grid } },
splitLine: { lineStyle: { color: THEME.grid } },
axisLabel: {
show: true, color: theme.text, fontSize: 9,
show: true, color: THEME.text, fontSize: 9,
fontFamily: 'JetBrains Mono, monospace',
},
})
@@ -688,14 +679,14 @@ function buildOption(
// 子图信息栏 graphic
const subStartTop = topPad + candleAvail + candleBottomPad
const infoGraphics = buildSubInfoGraphics(data, infoIdx, activeIndicators, subStartTop, theme)
const infoGraphics = buildSubInfoGraphics(data, infoIdx, activeIndicators, subStartTop)
return {
animation: false,
backgroundColor: theme.bg,
backgroundColor: THEME.bg,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', crossStyle: { color: theme.crosshair } },
axisPointer: { type: 'cross', crossStyle: { color: '#555' } },
backgroundColor: 'transparent',
borderWidth: 0,
textStyle: { fontSize: 0 },
@@ -704,8 +695,7 @@ function buildOption(
axisPointer: {
link: [{ xAxisIndex: 'all' }],
label: {
backgroundColor: theme.tooltipBg,
color: theme.tooltipText,
backgroundColor: '#333',
fontFamily: 'JetBrains Mono, monospace',
fontSize: 10,
},
@@ -746,9 +736,6 @@ export function EChartsCandlestick({
visibleBars = 60,
activeIndicators = [],
}: Props) {
const chartTheme = useChartTheme()
const theme = useMemo(() => getTHEME(chartTheme), [chartTheme])
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<ECharts | null>(null)
const dataRef = useRef(data)
@@ -767,8 +754,6 @@ export function EChartsCandlestick({
const chartHeightRef = useRef(300)
const subTotalHRef = useRef(0)
const getInfoBarHTMLRef = useRef<() => string>(() => '')
const themeRef = useRef(theme)
themeRef.current = theme
// 强制刷新信息栏 DOM 的回调
const infoBarRef = useRef<HTMLDivElement>(null)
@@ -780,7 +765,7 @@ export function EChartsCandlestick({
const chart = chartRef.current
if (!chart) return
const subStartTop = chartHeightRef.current - subTotalHRef.current
const infoGraphics = buildSubInfoGraphics(curData, idx, activeIndicatorsRef.current, subStartTop, themeRef.current)
const infoGraphics = buildSubInfoGraphics(curData, idx, activeIndicatorsRef.current, subStartTop)
if (infoGraphics.length > 0) {
chart.setOption({ graphic: infoGraphics }, { lazyUpdate: true })
}
@@ -829,19 +814,19 @@ export function EChartsCandlestick({
const prev = idx > 0 ? data[idx - 1] : null
const chg = prev ? d.close - prev.close : 0
const isUp = chg >= 0
const clr = isUp ? theme.bull : theme.bear
const clr = isUp ? THEME.bull : THEME.bear
const floatShares = stockInfo?.float_shares
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
html += `<span style="color:${theme.text}">${d.date}</span>`
html += `<span style="color:${theme.text}">开</span>`
html += `<span style="color:${d.open >= d.close ? theme.bear : theme.bull}">${d.open.toFixed(2)}</span>`
html += `<span style="color:${theme.text}">高</span>`
html += `<span style="color:${theme.bull}">${d.high.toFixed(2)}</span>`
html += `<span style="color:${theme.text}">低</span>`
html += `<span style="color:${theme.bear}">${d.low.toFixed(2)}</span>`
html += `<span style="color:${theme.text}">收</span>`
html += `<span style="color:${THEME.text}">${d.date}</span>`
html += `<span style="color:${THEME.text}">开</span>`
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">高</span>`
html += `<span style="color:${THEME.bull}">${d.high.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">低</span>`
html += `<span style="color:${THEME.bear}">${d.low.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">收</span>`
html += `<span style="color:${clr};font-weight:600">${d.close.toFixed(2)}</span>`
// 涨跌幅 (收盘后, 换手前; 和收间隔一些距离)
if (prev) {
@@ -849,18 +834,18 @@ export function EChartsCandlestick({
html += `<span style="color:${clr};margin-left:8px">${isUp ? '+' : ''}${chgPct.toFixed(2)}%</span>`
}
if (turnoverRate != null) {
html += `<span style="color:${theme.text}">换手</span>`
html += `<span style="color:${theme.text}">${turnoverRate.toFixed(2)}%</span>`
html += `<span style="color:${THEME.text}">换手</span>`
html += `<span style="color:${THEME.text}">${turnoverRate.toFixed(2)}%</span>`
}
html += `</div>`
// 第二行: MA + BOLL
if (showMA) {
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
if (d.ma5 != null) html += `<span style="color:${theme.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
if (d.ma10 != null) html += `<span style="color:${theme.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
if (d.ma20 != null) html += `<span style="color:${theme.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
if (d.ma60 != null) html += `<span style="color:${theme.ma60}">MA60:${Number(d.ma60).toFixed(2)}</span>`
if (d.ma5 != null) html += `<span style="color:${THEME.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
if (d.ma10 != null) html += `<span style="color:${THEME.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
if (d.ma20 != null) html += `<span style="color:${THEME.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
if (d.ma60 != null) html += `<span style="color:${THEME.ma60}">MA60:${Number(d.ma60).toFixed(2)}</span>`
if (d.boll_upper != null && activeIndicators.includes('boll')) {
html += `<span style="color:#E879F9">BOLL:${Number(d.boll_upper).toFixed(2)}/${Number(d.ma20).toFixed(2)}/${Number(d.boll_lower).toFixed(2)}</span>`
}
@@ -868,7 +853,7 @@ export function EChartsCandlestick({
}
return html
}, [data, stockInfo, showMA, activeIndicators, theme])
}, [data, stockInfo, showMA, activeIndicators])
getInfoBarHTMLRef.current = getInfoBarHTML
// data 变化时重置 infoIdx
@@ -976,7 +961,7 @@ export function EChartsCandlestick({
const isBuy = m.kind === 'buy'
const isSell = m.kind === 'sell'
if (m.above) {
const dotColor = m.color ?? (isBuy ? '#FACC15' : theme.text)
const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text)
if (compact) {
markPointData.push({
name: m.date, coord: [m.date, d.high],
@@ -1004,11 +989,11 @@ export function EChartsCandlestick({
symbol: 'arrow', symbolSize: 12,
symbolRotate: isBuy ? 0 : 180,
symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'],
itemStyle: { color: isBuy ? theme.bull : isSell ? theme.bear : theme.text },
itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : THEME.text },
label: {
show: !!m.label, formatter: m.label ?? '',
position: isBuy ? 'bottom' : 'top', distance: 8,
color: theme.text, fontSize: 10,
color: THEME.text, fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
},
})
@@ -1036,7 +1021,6 @@ export function EChartsCandlestick({
activeIndicators, chartHeight,
infoIdxRef.current,
linkedPrice,
theme,
)
chart.setOption(option, true)
@@ -1054,7 +1038,7 @@ export function EChartsCandlestick({
if (infoEl) {
infoEl.innerHTML = getInfoBarHTML()
}
}, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML, theme])
}, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML])
// 渲染信息栏容器 (内容由 JS 直接写入)
const initialHTML = useMemo(() => {
@@ -1064,16 +1048,16 @@ export function EChartsCandlestick({
const floatShares = stockInfo?.float_shares
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
html += `<span style="color:${theme.text}">${d.date}</span>`
html += `<span style="color:${theme.text}">开</span>`
html += `<span style="color:${d.open >= d.close ? theme.bear : theme.bull}">${d.open.toFixed(2)}</span>`
html += `<span style="color:${theme.text}">高</span>`
html += `<span style="color:${theme.bull}">${d.high.toFixed(2)}</span>`
html += `<span style="color:${theme.text}">低</span>`
html += `<span style="color:${theme.bear}">${d.low.toFixed(2)}</span>`
html += `<span style="color:${theme.text}">收</span>`
html += `<span style="color:${THEME.text}">${d.date}</span>`
html += `<span style="color:${THEME.text}">开</span>`
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">高</span>`
html += `<span style="color:${THEME.bull}">${d.high.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">低</span>`
html += `<span style="color:${THEME.bear}">${d.low.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">收</span>`
const prevClose0 = data[idx-1]?.close ?? d.close
const clr0 = d.close >= prevClose0 ? theme.bull : theme.bear
const clr0 = d.close >= prevClose0 ? THEME.bull : THEME.bear
html += `<span style="color:${clr0};font-weight:600">${d.close.toFixed(2)}</span>`
// 涨跌幅 (收盘后, 换手前; 和收间隔一些距离)
if (idx > 0) {
@@ -1081,29 +1065,30 @@ export function EChartsCandlestick({
html += `<span style="color:${clr0};margin-left:8px">${chgPct0 >= 0 ? '+' : ''}${chgPct0.toFixed(2)}%</span>`
}
if (turnoverRate != null) {
html += `<span style="color:${theme.text}">换手</span>`
html += `<span style="color:${theme.text}">${turnoverRate.toFixed(2)}%</span>`
html += `<span style="color:${THEME.text}">换手</span>`
html += `<span style="color:${THEME.text}">${turnoverRate.toFixed(2)}%</span>`
}
html += `</div>`
if (showMA) {
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
if (d.ma5 != null) html += `<span style="color:${theme.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
if (d.ma10 != null) html += `<span style="color:${theme.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
if (d.ma20 != null) html += `<span style="color:${theme.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
if (d.ma60 != null) html += `<span style="color:${theme.ma60}">MA60:${Number(d.ma60).toFixed(2)}</span>`
if (d.ma5 != null) html += `<span style="color:${THEME.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
if (d.ma10 != null) html += `<span style="color:${THEME.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
if (d.ma20 != null) html += `<span style="color:${THEME.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
if (d.ma60 != null) html += `<span style="color:${THEME.ma60}">MA60:${Number(d.ma60).toFixed(2)}</span>`
if (d.boll_upper != null && activeIndicators.includes('boll')) {
html += `<span style="color:#E879F9">BOLL:${Number(d.boll_upper).toFixed(2)}/${Number(d.ma20).toFixed(2)}/${Number(d.boll_lower).toFixed(2)}</span>`
}
html += `</div>`
}
return html
}, [data, stockInfo, showMA, activeIndicators, theme])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="w-full">
{/* 主图信息栏 — 内容由 JS 直接操作 innerHTML */}
{showInfoBar && (
<div ref={infoBarRef} style={{ backgroundColor: theme.tooltipBg }}
<div ref={infoBarRef} style={{ backgroundColor: 'rgba(39,39,42,0.6)' }}
dangerouslySetInnerHTML={{ __html: initialHTML }} />
)}
@@ -2,26 +2,19 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import type { MinuteKlineRow } from '@/lib/api'
import { useChartTheme, type ChartTheme } from '@/lib/chartTheme'
type YMode = 'adaptive' | 'limit'
function getTHEME(ct: ChartTheme) {
return {
line: '#3B82F6',
areaFill: 'rgba(59,130,246,0.40)',
avgLine: '#F59E0B',
refLine: ct.crosshair,
volUp: 'rgba(240,68,56,0.6)',
volDown: 'rgba(18,183,106,0.6)',
text: ct.text,
grid: ct.splitLine,
border: ct.axisLine,
tooltipBg: ct.tooltipBg,
tooltipBorder: ct.tooltipBorder,
tooltipText: ct.tooltipText,
crosshair: ct.crosshair,
}
const THEME = {
line: '#3B82F6',
areaFill: 'rgba(59,130,246,0.40)',
avgLine: '#F59E0B',
refLine: 'rgba(255,255,255,0.25)',
volUp: 'rgba(240,68,56,0.6)',
volDown: 'rgba(18,183,106,0.6)',
text: '#A1A1AA',
grid: 'rgba(255,255,255,0.04)',
border: '#27272A',
}
interface Props {
@@ -115,18 +108,7 @@ function getLimitPrices(prevClose: number, symbol?: string): {
return { limitUp, limitDown, upPct, downPct }
}
function buildOption(
data: MinuteKlineRow[],
prevClose: number | undefined,
avgPrices: number[],
lineColor: string,
areaColor: string,
yMode: YMode,
theme: ReturnType<typeof getTHEME>,
symbol?: string,
showLimitLines = true,
showAvgLine = true,
): EChartsOption {
function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, symbol?: string, showLimitLines = true, showAvgLine = true): EChartsOption {
// 将数据映射到全天时间轴上的正确位置
const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i]))
const closes = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
@@ -147,7 +129,7 @@ function buildOption(
volumes[idx] = {
value: data[i].volume,
itemStyle: {
color: data[i].close > data[i].open ? theme.volUp : data[i].close < data[i].open ? theme.volDown : volNeutral,
color: data[i].close > data[i].open ? THEME.volUp : data[i].close < data[i].open ? THEME.volDown : volNeutral,
},
}
}
@@ -168,7 +150,7 @@ function buildOption(
if (prevClose != null) {
markLineData.push({
yAxis: prevClose,
lineStyle: { color: theme.refLine, type: 'dashed', width: 1 },
lineStyle: { color: THEME.refLine, type: 'dashed', width: 1 },
label: { show: false },
symbol: 'none',
})
@@ -255,16 +237,16 @@ function buildOption(
type: 'cross',
label: {
show: true,
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
backgroundColor: 'rgba(39,39,42,0.9)',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
padding: [2, 5],
color: theme.text,
color: '#A1A1AA',
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
},
crossStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
crossStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
},
},
axisPointer: {
@@ -281,14 +263,14 @@ function buildOption(
boundaryGap: false,
axisPointer: {
show: true,
lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
label: {
show: true,
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
backgroundColor: 'rgba(39,39,42,0.9)',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
padding: [2, 4],
color: theme.text,
color: '#A1A1AA',
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (params: any) => {
@@ -298,7 +280,7 @@ function buildOption(
},
axisLine: { show: false },
axisLabel: {
color: theme.text,
color: THEME.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: xAxisLabelFormatter,
@@ -307,7 +289,7 @@ function buildOption(
axisTick: { show: false },
splitLine: {
show: true,
lineStyle: { color: theme.grid },
lineStyle: { color: 'rgba(255,255,255,0.04)' },
},
},
{
@@ -330,7 +312,7 @@ function buildOption(
splitArea: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: theme.grid } },
splitLine: { lineStyle: { color: THEME.grid } },
axisPointer: {
label: {
formatter: (params: any) => {
@@ -340,7 +322,7 @@ function buildOption(
},
},
axisLabel: {
color: theme.text,
color: THEME.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => v.toFixed(2),
@@ -378,7 +360,7 @@ function buildOption(
},
},
axisLabel: {
color: theme.text,
color: THEME.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => {
@@ -409,7 +391,7 @@ function buildOption(
smooth: false,
symbol: 'none',
cursor: 'crosshair',
lineStyle: { width: 1, color: theme.avgLine },
lineStyle: { width: 1, color: THEME.avgLine },
connectNulls: true,
}] : []),
{
@@ -425,9 +407,6 @@ function buildOption(
}
export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, onPriceHover, showLimitLines = true, showAvgLine = true }: Props) {
const chartTheme = useChartTheme()
const theme = useMemo(() => getTHEME(chartTheme), [chartTheme])
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<ECharts | null>(null)
const roRef = useRef<ResizeObserver | null>(null)
@@ -515,11 +494,11 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o
}
fullDayToDataIdx.current = mapping
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, theme, symbol, showLimitLines, showAvgLine), true)
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine), true)
} else {
chart.clear()
}
}, [data, prevClose, height, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine, theme])
}, [data, prevClose, height, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine])
useEffect(() => {
return () => {
@@ -569,7 +548,7 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o
</button>
</div>
</div>}
<div style={{ backgroundColor: theme.tooltipBg }}>
<div style={{ backgroundColor: 'rgba(39,39,42,0.6)' }}>
{/* 第一行: 日期 + OHLC */}
<div className="flex items-center gap-x-2 px-2 font-mono text-[11px] select-none flex-wrap" style={{ height: 20 }}>
{!d && <span className="text-muted"></span>}
@@ -596,8 +575,8 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o
<span style={{ color: priceClr }}>{d.close.toFixed(2)}</span>
</span>
{showAvgLine && <span className="flex items-center gap-x-1">
<span style={{ display: 'inline-block', width: 14, height: 2, background: theme.avgLine }} />
<span style={{ color: theme.avgLine }}>{avg?.toFixed(2)}</span>
<span style={{ display: 'inline-block', width: 14, height: 2, background: THEME.avgLine }} />
<span style={{ color: THEME.avgLine }}>{avg?.toFixed(2)}</span>
</span>}
<span className="text-muted"></span>
<span className="text-secondary">{d.volume.toFixed(0)}</span>
@@ -22,7 +22,7 @@ export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose
const [testing, setTesting] = useState<Record<string, boolean>>({})
const [switching, setSwitching] = useState<string | null>(null)
// 动态加载端点清单 —— 前端无法跨域直连数据源官网,走后端代理
// 动态加载端点清单 —— 前端无法跨域直连 tickflow.org,走后端代理
const { data, isLoading } = useQuery({
queryKey: QK.endpoints,
queryFn: api.listEndpoints,
@@ -0,0 +1,31 @@
import { Clock } from 'lucide-react'
import type { StockRef } from '@/lib/useLastStock'
/**
* "上次查看"个股胶囊 —— 显示在 PageHeader 右侧。
* 上方名称、下方代码,小字体二排;点击恢复该个股的查看。
*/
export function LastStockChip({
stock,
onSelect,
}: {
stock: StockRef | null
onSelect?: (symbol: string, name: string) => void
}) {
if (!stock) return null
return (
<button
onClick={() => onSelect?.(stock.symbol, stock.name)}
title={`继续查看 ${stock.name}`}
className="group inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-elevated/40 px-2 py-1 hover:border-border hover:bg-elevated transition-colors"
>
<Clock className="h-3 w-3 text-muted shrink-0" />
<span className="flex flex-col items-start leading-tight">
<span className="text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors max-w-[7em] truncate">
{stock.name}
</span>
<span className="text-[9px] font-mono text-muted">{stock.symbol}</span>
</span>
</button>
)
}
+121 -42
View File
@@ -5,6 +5,10 @@ import { motion } from 'framer-motion'
import { useQuoteStream } from '@/lib/useQuoteStream'
import { ToastContainer } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast'
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
import { AiReportBubble } from '@/components/financials/AiReportBubble'
import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost'
import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble'
import {
useCapabilities,
useSettings,
@@ -18,23 +22,37 @@ import {
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import {
Star,
ScanSearch,
History,
FileText,
Settings,
Key,
Database,
Timer,
Loader2,
LayoutDashboard,
Tags,
TrendingUp,
Flame,
BarChart3,
Sparkles,
Layers3,
Landmark,
Cable,
RadioTower,
CheckCircle2,
BookOpenCheck,
ExternalLink,
X,
} from 'lucide-react'
import { Logo } from './Logo'
import { useTheme } from './ThemeProvider'
import { api, type IndexQuote } from '@/lib/api'
import { cn } from '@/lib/cn'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
const BRAND = '#8B5CF6'
const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA'
const CORE_INDEXES = [
{ symbol: '000001.SH', name: '上证指数' },
@@ -46,8 +64,20 @@ const CORE_INDEXES = [
type CoreIndex = (typeof CORE_INDEXES)[number]
const nav = [
{ to: '/', label: '看板', icon: LayoutDashboard },
{ to: '/data', label: '数据', icon: Database },
{ to: '/', label: '看板', icon: LayoutDashboard },
{ to: '/watchlist', label: '自选', icon: Star },
{ to: '/screener', label: '策略', icon: ScanSearch },
{ to: '/backtest', label: '回测', icon: History },
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
{ to: '/financials', label: '财务分析', icon: FileText },
{ to: '/monitor', label: '监控中心', icon: RadioTower },
{ to: '/review', label: '复盘', icon: BookOpenCheck },
{ to: '/indices', label: '指数', icon: BarChart3 },
{ to: '/trading', label: '交易', icon: Cable },
{ to: '/data', label: '数据', icon: Database },
] as const
function fmtIndexValue(v: number | null | undefined) {
@@ -130,7 +160,7 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '基础日K · 单股查询',
desc: '基础日K · 自选实时',
tagBg: { background: 'rgba(113,113,122,0.3)' },
dotStyle: { background: '#71717a' },
labelTextStyle: { color: '#a1a1aa' },
@@ -156,8 +186,8 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
}
const t = tierConfig[base] || tierConfig.none
// none 档显示文「」,无 label 时显示「无档
const displayLabel = isNone ? '' : (label || '')
// none 档显示文「None」,无 label 时显示「None
const displayLabel = isNone ? 'None' : (label || 'None')
return (
<NavLink
@@ -173,7 +203,7 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-foreground"></span>
<span className="text-xs font-medium text-foreground">TickFlow</span>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ ...t.dotStyle, ...(base === 'expert' ? { animation: 'pulse 2s infinite' } : {}) }}
@@ -228,13 +258,17 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st
export function Layout() {
// ===== 共享 hooks (替代内联 useQuery) =====
const { resolved } = useTheme()
const isDark = resolved === 'dark'
const { data: caps } = useCapabilities()
const { data: settingsState } = useSettings()
const { data: versionData } = useVersion()
const { data: prefs } = usePreferences()
const { data: quoteStatus } = useQuoteStatus()
// poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE)
const { data: quoteStatus } = useQuoteStatus({ poll: true })
const { data: analysisMenus } = useQuery({
queryKey: QK.analysisMenus,
queryFn: api.analysisMenus,
})
// 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈
const { data: pipelineJobs } = useQuery({
queryKey: QK.pipelineJobs,
@@ -263,6 +297,8 @@ export function Layout() {
const navigate = useNavigate()
const version = versionData?.version
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
// Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
const [dismissFreeHint, setDismissFreeHint] = useState(false)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol)
const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol))
@@ -281,8 +317,10 @@ export function Layout() {
const toggleQuote = useToggleRealtimeQuotes()
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
// none/free 档(无实时行情权限)→ rank < starter(1)
const isFreeTier = tierRank(caps?.label ?? '') < 1
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isWatchlistMode = tier === 0
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
const alertsTotalQuery = useQuery({
@@ -298,8 +336,10 @@ export function Layout() {
if (alertsTotal != null) setAlertTotal(alertsTotal)
}, [alertsTotal])
// 当前仅开放看板和数据业务,扩展分析菜单暂不展示
const analysisNav: { to: string; label: string; icon: typeof LayoutDashboard }[] = []
// 合并内置页面 + 可见的扩展分析菜单
const analysisNav = (analysisMenus?.items ?? [])
.filter(m => m.visible)
.map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 }))
const allNav = [...nav, ...analysisNav]
const savedOrder = prefs?.nav_order ?? []
@@ -325,7 +365,12 @@ export function Layout() {
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
if (tierRank(fresh.label ?? '') < 1) return
const freshTier = tierRank(fresh.label ?? '')
if (freshTier < 0) return
if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
navigate('/watchlist')
return
}
}
await toggleQuote.mutateAsync(enabled)
// 仅在交易时段立即获取一次行情
@@ -342,20 +387,20 @@ export function Layout() {
<div className="flex items-center gap-2.5">
<Logo
size={28}
className={cn('shrink-0', isDark && 'drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]')}
className="shrink-0 drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]"
style={{ color: BRAND }}
/>
<div
className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight"
style={{ textShadow: isDark ? `0 0 10px ${BRAND}44` : 'none' }}
style={{ textShadow: `0 0 10px ${BRAND}44` }}
>
<div>A股</div>
<div></div>
<div>TickFlow</div>
<div>Stock Panel</div>
</div>
</div>
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
Quant · Terminal
</div>
<div
@@ -363,14 +408,12 @@ export function Layout() {
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
/>
{settingsState?.mode === 'none' && (
<TierBadge
label={caps?.label ?? ''}
hasKey={false}
/>
)}
<TierBadge
label={caps?.label ?? ''}
hasKey={settingsState?.mode !== 'none'}
/>
<AIConfigBadge
configured={settingsState?.has_ai_key}
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
model={settingsState?.ai_model}
/>
</div>
@@ -393,6 +436,12 @@ export function Layout() {
<>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{label}</span>
{/* 个股分析 Beta 标识 */}
{(to === '/stock-analysis' || to === '/review') && (
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
Beta
</span>
)}
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
{to === '/data' && isDataSyncing && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
@@ -410,13 +459,27 @@ export function Layout() {
{/* 全局行情开关 */}
<div className="border-t border-border px-3 py-2.5 shrink-0">
{isFreeTier ? (
/* Free 档位 — 显示升级提示 */
<div className="flex items-center justify-between">
<span className="text-xs text-secondary truncate"></span>
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
Starter+
</span>
{isNoneTier ? (
<div>
<div className="flex items-center justify-between">
<span className="text-xs text-secondary truncate"></span>
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
Free+
</span>
</div>
<div className="mt-1.5 text-[10px] leading-snug text-muted">
<a
href={TICKFLOW_REGISTER_URL}
target="_blank"
rel="noreferrer"
className="mx-1 inline-flex items-baseline gap-0.5 text-accent/80 hover:text-accent hover:underline"
>
TickFlow
<ExternalLink className="h-2.5 w-2.5 self-center" />
</a>
</div>
</div>
) : (
/* Starter+ — 开关 + 跳转设置 */
@@ -430,14 +493,14 @@ export function Layout() {
: 'bg-muted'
}`} />
<span className="text-xs text-secondary truncate">
· {realtimeModeLabel}
</span>
<button
onClick={() => navigate('/settings?tab=monitoring')}
className="text-secondary hover:text-foreground transition-colors shrink-0"
title="实时监控设置"
>
<Timer className="h-3 w-3" />
<Settings className="h-3 w-3" />
</button>
</div>
<button
@@ -457,16 +520,28 @@ export function Layout() {
)}
{/* 状态提示 */}
{realtimeEnabled && !isFreeTier && (
<div className="mt-1.5 text-[10px] leading-snug">
{realtimeEnabled && !isNoneTier && (
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
{isWatchlistMode && !dismissFreeHint && (
<div className="flex items-start gap-1 text-amber-400/80">
<span className="flex-1"> 5 Starter+</span>
<button
onClick={() => setDismissFreeHint(true)}
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
title="关闭提示"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
)}
{isRunning && isTrading ? (
<span className="text-accent"></span>
<div className="text-accent"></div>
) : realtimeEnabled && !isTrading ? (
<span className="text-warning/70"></span>
<div className="text-warning/70"></div>
) : null}
</div>
)}
{showSidebarQuotes && !isFreeTier && (
{showSidebarQuotes && !isWatchlistMode && !isNoneTier && (
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
)}
</div>
@@ -504,6 +579,10 @@ export function Layout() {
</motion.main>
<ToastContainer />
<AlertToastContainer />
<AiAnalysisHost />
<AiReportBubble />
<StockAnalysisHost />
<StockAnalysisBubble />
</div>
)
}
+1 -1
View File
@@ -22,7 +22,7 @@ export function Logo({ className, size = 32, style }: LogoProps) {
className={className}
style={style}
role="img"
aria-label="Stock Panel"
aria-label="TickFlow Stock Panel"
>
{/* 左方括号 */}
<path
@@ -0,0 +1,445 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Repeat, Sparkles, ArrowDownUp, RefreshCw, AlertCircle } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
import { fmtPct } from '@/lib/format'
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
interface Props {
onClose: () => void
}
const DEFAULT_DAYS = 12
const ROW_HEIGHT = 30 // 每行高度(px), 与单元格样式配合
const OVERSCAN = 8 // 上下额外渲染行数, 减少滚动时的白屏闪烁
const MIN_DAYS = 7
const MAX_DAYS = 30
// 涨幅 → 背景色梯度(A 股语义: 红涨绿跌)。强度越大色越深, 一眼看出强势/弱势概念
function pctBgClass(pct: number): string {
if (pct >= 0.05) return 'bg-bull/25'
if (pct >= 0.03) return 'bg-bull/18'
if (pct >= 0.01) return 'bg-bull/10'
if (pct > -0.01) return ''
if (pct > -0.03) return 'bg-bear/10'
if (pct > -0.05) return 'bg-bear/18'
return 'bg-bear/25'
}
// 把 "2026-07-01" 格式化成 "7/01" 紧凑显示(表头窄列)
function shortDate(s: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
if (!m) return s
return `${Number(m[2])}/${m[3]}`
}
// 排名 → 前景色(A 股语义: 红=强, 绿=弱)。前 10 红, 后 10 绿, 中间默认强调色。
// total 兜底: 概念总数未知时只判前 10, 不判后 10。
function rankColorClass(rank: number, total: number): string {
if (rank <= 10) return 'text-bull'
if (total > 20 && rank > total - 10) return 'text-bear'
return 'text-accent'
}
export function RpsRotationDialog({ onClose }: Props) {
const [days, setDays] = useState(DEFAULT_DAYS)
const [reversed, setReversed] = useState(false) // false=高→低, true=低→高
const [selected, setSelected] = useState<string | null>(null) // 点中的概念名, 高亮追踪
// ---- AI 轮动分析状态 (组件内, 不建全局 store: 切页即关对话框) ----
const [analysis, setAnalysis] = useState('') // 累积的 Markdown 报告
const [analyzing, setAnalyzing] = useState(false) // 生成中
const [analysisError, setAnalysisError] = useState('') // 错误信息
const [analysisMeta, setAnalysisMeta] = useState<{ summary?: string } | null>(null)
const [focus, setFocus] = useState('') // 用户追加的关注点
const runAnalysis = useCallback(async (daysParam: number, focusParam: string) => {
setAnalyzing(true)
setAnalysis('')
setAnalysisError('')
setAnalysisMeta(null)
try {
for await (const ev of api.rotationAnalyzeStream(daysParam, focusParam)) {
if (ev.type === 'meta') setAnalysisMeta({ summary: ev.summary })
else if (ev.type === 'delta') setAnalysis(a => a + (ev.content ?? ''))
else if (ev.type === 'error') setAnalysisError(ev.message ?? '未知错误')
// done: 无操作
}
} catch (e) {
setAnalysisError(e instanceof Error ? e.message : String(e))
} finally {
setAnalyzing(false)
}
}, [])
// 数据请求: React Query 缓存, 同 days 5 分钟内重开秒开
const { data, isLoading, error } = useQuery({
queryKey: QK.rpsRotation(days),
queryFn: () => api.rpsRotation(days),
staleTime: 5 * 60 * 1000,
})
const dates = data?.dates ?? []
const columns = data?.columns ?? {}
const conceptCount = data?.concept_count ?? 0
// 行数 = 最长那列的长度(理论上每天概念数应一致, 取最大兜底)
const rowCount = useMemo(
() => dates.reduce((m, d) => Math.max(m, columns[d]?.length ?? 0), 0),
[dates, columns],
)
// 行索引: 翻转时不重排数据, 只翻转访问索引(省一次大数组操作)
const getRowIndex = useCallback(
(displayIdx: number) => (reversed ? rowCount - 1 - displayIdx : displayIdx),
[reversed, rowCount],
)
// ---- 手写虚拟滚动 ----
// 监听滚动容器 scrollTop, 只渲染 [firstIdx, lastIdx] 范围内的行。
// 387 行只画可视的 ~25 行 + overscan, DOM 恒定 ~30 行 × N 列, 滚动 60fps。
const scrollRef = useRef<HTMLDivElement>(null)
// AI 报告区滚动容器: 流式生成时自动滚到底部
const analysisRef = useRef<HTMLDivElement>(null)
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 25 })
// 流式生成中: analysis 每次追加都把报告区滚到底部, 跟踪最新文字
useEffect(() => {
if (!analyzing) return
const el = analysisRef.current
if (el) el.scrollTop = el.scrollHeight
}, [analysis, analyzing])
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
const scrollTop = el.scrollTop
const viewportH = el.clientHeight
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN)
const end = Math.min(rowCount, Math.ceil((scrollTop + viewportH) / ROW_HEIGHT) + OVERSCAN)
setVisibleRange(prev => (prev.start === start && prev.end === end ? prev : { start, end }))
}, [rowCount])
useEffect(() => {
// rowCount 变化(切天数/数据到达)时重算可视范围
handleScroll()
}, [handleScroll, rowCount])
// ESC 关闭
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
// 选中概念的追踪行: 找出它在每个日期列的(排名, 涨幅)。
// 每列已按涨幅降序排好, 故排名 = 该概念在数组里的索引 + 1。
// 未入选该日(概念当天无数据)显示空, 便于横向看排名变化。
const selectedRow = useMemo(() => {
if (!selected) return null
const cells: ({ rank: number; pct: number } | null)[] = []
for (const d of dates) {
const col = columns[d] ?? []
const idx = col.findIndex(([name]) => name === selected)
cells.push(idx >= 0 ? { rank: idx + 1, pct: col[idx][1] } : null)
}
return cells
}, [selected, dates, columns])
const renderRows = useMemo(() => {
const rows: JSX.Element[] = []
for (let displayIdx = visibleRange.start; displayIdx < visibleRange.end; displayIdx++) {
const rawIdx = getRowIndex(displayIdx)
const cells = dates.map((d) => {
const cell = columns[d]?.[rawIdx]
if (!cell) {
return (
<td key={d} className="px-2 py-1 text-center text-muted/40">
<span className="text-[10px]"></span>
</td>
)
}
const [name, pct] = cell
const isSelected = selected === name
return (
<td
key={d}
onClick={() => setSelected(prev => prev === name ? null : name)}
className={cn(
'px-2 py-1 cursor-pointer whitespace-nowrap text-center align-middle transition-colors',
pctBgClass(pct),
isSelected && 'ring-1 ring-inset ring-accent bg-accent/20',
)}
>
<div className="flex flex-col items-center gap-0.5 leading-tight">
<span className={cn(
'text-[11px] max-w-[84px] truncate',
isSelected ? 'text-accent font-medium' : 'text-secondary',
)} title={name}>{name}</span>
<span className={cn(
'text-[10px] tabular-nums',
pct > 0 ? 'text-bull' : pct < 0 ? 'text-bear' : 'text-muted',
)}>{fmtPct(pct)}</span>
</div>
</td>
)
})
rows.push(
<tr
key={displayIdx}
style={{ height: ROW_HEIGHT }}
className="border-b border-border/30"
>
<td className="sticky left-0 z-10 bg-surface px-2 text-center text-[10px] text-muted tabular-nums border-r border-border/40">
{displayIdx + 1}
</td>
{cells}
</tr>,
)
}
return rows
}, [visibleRange, getRowIndex, dates, columns, selected])
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
className="w-[92vw] max-w-[1100px] h-[88vh] bg-surface border border-border rounded-card shadow-xl flex flex-col"
>
{/* 标题栏 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Repeat className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-foreground"></span>
<span className="text-[11px] text-muted">
{conceptCount > 0 ? `${dates.length} 天 · ${conceptCount} 个概念` : '暂无数据'}
</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
<X className="h-4 w-4 text-muted" />
</button>
</div>
{/* 上半区: AI 轮动分析 */}
<div className="shrink-0 border-b border-border flex flex-col max-h-[42%]">
{/* 标题栏: 标题 + meta 摘要 + focus 输入 + 触发按钮 */}
<div className="flex items-center gap-2 px-4 py-1.5 bg-elevated/30 shrink-0">
<Sparkles className={cn('h-3.5 w-3.5 text-accent/60', analyzing && 'animate-pulse')} />
<span className="text-[11px] text-muted shrink-0">AI </span>
{analysisMeta?.summary && (
<span className="text-[11px] text-accent/80 truncate">{analysisMeta.summary}</span>
)}
<div className="flex items-center gap-1.5 ml-auto">
<input
type="text"
value={focus}
onChange={e => setFocus(e.target.value)}
placeholder="关注点(可选)"
disabled={analyzing}
className="w-28 px-2 py-0.5 text-[11px] bg-elevated/50 border border-border rounded-btn text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/40 disabled:opacity-50"
/>
<button
onClick={() => runAnalysis(days, focus)}
disabled={analyzing}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] transition-colors cursor-pointer border',
analyzing
? 'opacity-60 cursor-not-allowed border-border text-muted'
: 'bg-accent/10 text-accent border-accent/30 hover:bg-accent/20',
)}
>
{analyzing
? <><RefreshCw className="h-3 w-3 animate-spin" /></>
: analysis
? <><RefreshCw className="h-3 w-3" /></>
: <><Sparkles className="h-3 w-3" /></>}
</button>
</div>
</div>
{/* 报告内容区: 四态渲染 */}
<div ref={analysisRef} className="flex-1 min-h-0 overflow-auto">
{analysisError ? (
<div className="flex items-center gap-2 px-4 py-4 text-[11px] text-danger">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span>{analysisError}</span>
<button
onClick={() => runAnalysis(days, focus)}
className="ml-auto text-accent hover:underline shrink-0"
></button>
</div>
) : analysis || analyzing ? (
<div className="px-4 py-2.5 text-[12px] leading-relaxed">
<MarkdownRenderer content={analysis} />
{analyzing && (
<span className="inline-block w-1.5 h-3.5 bg-accent animate-pulse align-middle ml-0.5" />
)}
</div>
) : (
<div className="px-4 py-4 text-center text-[11px] text-muted/60">
,AI 线 / / 退 / vs游资 {days}
</div>
)}
</div>
</div>
{/* 工具栏 */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-border shrink-0">
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-muted"></span>
<input
type="range"
min={MIN_DAYS}
max={MAX_DAYS}
step={1}
value={days}
onChange={e => setDays(Number(e.target.value))}
className="w-24 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-secondary tabular-nums w-5">{days}</span>
</div>
<button
onClick={() => setReversed(r => !r)}
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] transition-colors cursor-pointer border',
reversed
? 'bg-accent/10 text-accent border-accent/30'
: 'border-border text-muted hover:text-secondary hover:bg-elevated',
)}
title="翻转排序(高↔低)"
>
<ArrowDownUp className="h-3 w-3" />
{reversed ? '低→高' : '高→低'}
</button>
{selected && (
<button
onClick={() => setSelected(null)}
className="text-[11px] text-accent hover:underline cursor-pointer"
>
{selected}
</button>
)}
</div>
{/* 下半区: 涨幅轮动矩阵(虚拟滚动) */}
<div className="flex-1 min-h-0 flex flex-col">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-[11px] text-danger">
,
</div>
) : rowCount === 0 ? (
<div className="flex items-center justify-center py-16 text-[11px] text-muted">
,
</div>
) : (
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-auto"
>
<table className="min-w-full border-collapse">
{/* 表头: 日期列, 最新在最左 */}
<thead className="sticky top-0 z-20 bg-surface">
<tr>
<th className="sticky left-0 z-30 bg-surface px-2 py-1.5 text-[10px] font-normal text-muted border-b border-r border-border/40">
#
</th>
{dates.map(d => (
<th
key={d}
className="px-2 py-1.5 text-[10px] font-normal text-muted border-b border-border/40 whitespace-nowrap text-center"
title={d}
>
{shortDate(d)}
</th>
))}
</tr>
{/* 选中概念追踪行: 在日期表头下方单独一行, 横向展示它在各日的排名+涨幅 */}
<AnimatePresence>
{selected && selectedRow && (
<motion.tr
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
className="border-b border-accent/20 bg-accent/5"
>
<td className="sticky left-0 z-30 bg-surface px-2 py-1 text-center border-r border-border/40">
<span className="text-[10px] text-accent truncate block max-w-[44px]" title={selected}>
{selected}
</span>
</td>
{selectedRow.map((cell, i) => (
<td key={i} className="px-2 py-1 text-center whitespace-nowrap align-middle">
{cell ? (
<div className="flex flex-col items-center gap-0.5 leading-tight">
<span className={cn(
'text-[11px] font-medium tabular-nums',
rankColorClass(cell.rank, conceptCount),
)}>
#{cell.rank}
</span>
<span className={cn(
'text-[10px] tabular-nums',
cell.pct > 0 ? 'text-bull' : cell.pct < 0 ? 'text-bear' : 'text-muted',
)}>
{fmtPct(cell.pct)}
</span>
</div>
) : (
<span className="text-[10px] text-muted/40"></span>
)}
</td>
))}
</motion.tr>
)}
</AnimatePresence>
</thead>
<tbody>
{/* 顶部占位: 把滚动位置撑起来 */}
{visibleRange.start > 0 && (
<tr style={{ height: visibleRange.start * ROW_HEIGHT }}>
<td colSpan={dates.length + 1} />
</tr>
)}
{renderRows}
{/* 底部占位 */}
{visibleRange.end < rowCount && (
<tr style={{ height: (rowCount - visibleRange.end) * ROW_HEIGHT }}>
<td colSpan={dates.length + 1} />
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{/* 底部提示 */}
<div className="px-4 py-1.5 border-t border-border shrink-0">
<span className="text-[10px] text-muted">
·
</span>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
@@ -31,7 +31,7 @@ function SealedDirBlock({ title, color, counts, rawTotal }: {
</div>
<div className="flex gap-3 px-1 text-[10px]">
<span className={`flex items-center gap-0.5 text-${color}`}><span className={`h-1 w-1 rounded-full bg-${color}`} /> {real}</span>
<span className="flex items-center gap-0.5 text-yellow-600 dark:text-yellow-500"><span className="h-1 w-1 rounded-full bg-yellow-500" /> {fake}</span>
<span className="flex items-center gap-0.5 text-yellow-500"><span className="h-1 w-1 rounded-full bg-yellow-500" /> {fake}</span>
{pending > 0 && (
<span className="flex items-center gap-0.5 text-muted"><span className="h-1 w-1 rounded-full bg-muted" /> {pending}</span>
)}
@@ -77,11 +77,11 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea
<div className="relative inline-flex items-center">
<button
onClick={() => setShowHint(v => !v)}
className="group inline-flex items-center gap-1 h-5 px-2 rounded-full bg-yellow-100 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 cursor-help transition-all hover:bg-yellow-200 dark:hover:bg-yellow-500/20 hover:border-yellow-400 dark:hover:border-yellow-500/50"
className="group inline-flex items-center gap-1 h-5 px-2 rounded-full bg-yellow-500/10 border border-yellow-500/30 cursor-help transition-all hover:bg-yellow-500/20 hover:border-yellow-500/50"
>
<span className="h-1.5 w-1.5 rounded-full bg-yellow-500" />
<span className="text-[10px] font-medium text-yellow-700 dark:text-yellow-600 leading-none">{label}</span>
<HelpCircle className="h-3 w-3 text-yellow-600/70 dark:text-yellow-500/70 group-hover:text-yellow-600 dark:group-hover:text-yellow-500 transition-colors" />
<span className="text-[10px] font-medium text-yellow-600 dark:text-yellow-500 leading-none">{label}</span>
<HelpCircle className="h-3 w-3 text-yellow-500/70 group-hover:text-yellow-500 transition-colors" />
</button>
<AnimatePresence>
{showHint && (
@@ -42,7 +42,7 @@ export function StockIntradayChart({
})
const minuteRows: MinuteKlineRow[] = useMemo(() => minute.data?.rows ?? [], [minute.data?.rows])
// source=none 表示本地无数据且数据源也拉不到 (停牌/复牌延迟/非交易日)
// source=none 表示本地无数据且 TickFlow 也拉不到 (停牌/复牌延迟/非交易日)
// 此时不弹"是否获取"询问窗, 只做静态提示, 避免误导用户去拉明知拉不到的数据
const sourceIsNone = minute.data?.source === 'none'
@@ -1,78 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { storage } from '@/lib/storage'
export type Theme = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
interface ThemeContextValue {
theme: Theme
resolved: ResolvedTheme
setTheme: (theme: Theme) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
function resolveTheme(theme: Theme): ResolvedTheme {
if (theme !== 'system') return theme
if (typeof window === 'undefined') return 'dark'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function updateMetaThemeColor(resolved: ResolvedTheme) {
if (typeof document === 'undefined') return
const meta = document.querySelector('meta[name="theme-color"]')
if (!meta) return
// 暗色保持品牌紫, 浅色使用浅色背景避免状态栏突兀
meta.setAttribute('content', resolved === 'dark' ? '#8B5CF6' : '#FAFAFA')
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const saved = storage.theme.get('system')
return saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system'
})
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(theme))
useEffect(() => {
const nextResolved = resolveTheme(theme)
setResolved(nextResolved)
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(nextResolved)
storage.theme.set(theme)
updateMetaThemeColor(nextResolved)
}, [theme])
useEffect(() => {
if (theme !== 'system') return
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
const nextResolved = resolveTheme('system')
setResolved(nextResolved)
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(nextResolved)
updateMetaThemeColor(nextResolved)
}
media.addEventListener('change', handler)
return () => media.removeEventListener('change', handler)
}, [theme])
const setTheme = (next: Theme) => {
setThemeState(next)
}
return (
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) {
throw new Error('useTheme must be used within ThemeProvider')
}
return ctx
}
@@ -0,0 +1,106 @@
/**
* 回测预热期徽标 — 点击弹出说明气泡。
*
* 解释「回测开头几个月没有交易」这一高频疑问: 技术指标需要历史数据预热,
* 系统会自动在回测起点之前多取约 120 天 (≈4 个月) 数据; 若本地数据恰好从
* 起点才开始, 开头几个月指标算不出、信号不触发, 属正常现象。
*
* 实现要点:
* - 点击触发 (非 hover), 移动端友好
* - 用 createPortal 渲染到 body, 绕开父容器 overflow 裁剪 (回测配置面板有 overflow-y-auto)
* - 全屏透明遮罩点击关闭 + ESC 关闭
* - 气泡位置 = 锚点 rect 实时计算, 自动判断向左/向右展开避免溢出屏幕
*/
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Info } from 'lucide-react'
interface Pos { top: number; left: number }
export function WarmupBadge() {
const [open, setOpen] = useState(false)
const anchorRef = useRef<HTMLButtonElement>(null)
const [pos, setPos] = useState<Pos>({ top: 0, left: 0 })
// 打开时根据锚点 rect 计算气泡位置 (向下方弹出)
useLayoutEffect(() => {
if (!open || !anchorRef.current) return
const rect = anchorRef.current.getBoundingClientRect()
const POPUP_W = 272
const GAP = 8
// 优先左对齐锚点; 右侧不够则右对齐; 兜底贴左边
let left = rect.left
if (left + POPUP_W > window.innerWidth - 8) {
left = rect.right - POPUP_W
}
left = Math.max(8, left)
setPos({ top: rect.bottom + GAP, left })
}, [open])
// ESC 关闭
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
return (
<>
<button
ref={anchorRef}
type="button"
onClick={() => setOpen(o => !o)}
className="inline-flex items-center gap-0.5 rounded-full px-1.5 text-[10px] text-amber-500/70 transition-colors hover:bg-amber-400/10 hover:text-amber-500"
title="为什么开头可能没交易?"
>
<Info className="h-3 w-3" strokeWidth={1.5} />
120
</button>
{createPortal(
<AnimatePresence>
{open && (
<>
{/* 全屏透明遮罩: 点击关闭 */}
<div
className="fixed inset-0 z-[60]"
onClick={() => setOpen(false)}
/>
{/* 气泡: 绝对定位到 body, 绕开 overflow 裁剪 */}
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.96 }}
transition={{ duration: 0.15 }}
style={{ position: 'fixed', top: pos.top, left: pos.left, width: 272 }}
className="z-[70] rounded-btn border border-border bg-surface p-3 text-[11px] leading-relaxed text-secondary shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="mb-1.5 font-medium text-foreground">?</div>
<p className="text-muted">
(MA / MACD / RSI )
<span className="font-medium text-amber-300"> 120 (4 )</span>
</p>
<p className="mt-1.5 text-muted">
, ,
<span className="text-secondary"> , bug</span>
</p>
<div className="mt-2 border-t border-border/60 pt-2 text-muted">
<span className="text-secondary">:</span> ,
</div>
{/* 小箭头指向锚点 */}
<div
className="absolute -top-1 h-2 w-2 rotate-45 border-l border-t border-border bg-surface"
style={{ left: 12 }}
/>
</motion.div>
</>
)}
</AnimatePresence>,
document.body,
)}
</>
)
}
@@ -12,9 +12,12 @@ import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import {
AlertCircle,
BarChart3,
ChevronDown,
Database,
DownloadCloud,
RefreshCw,
Search,
Settings2,
Tags,
@@ -449,3 +452,50 @@ export function ConfigButton({ onClick }: { onClick: () => void }) {
</button>
)
}
/**
* 内置预设 (概念/行业) 数据获取空状态。
*
* 当检测到内置预设存在但无数据时, 展示图标 + 提示 + 「获取数据」按钮,
* 让用户手动触发拉取 (POST /api/ext-data/presets/{id}/fetch), 而非自动拉取。
*/
export function PresetFetchState({
title,
hint,
isLoading,
error,
onFetch,
}: {
title: string
hint: string
isLoading: boolean
error: unknown
onFetch: () => void
}) {
const errMsg = error instanceof Error ? error.message : error ? String(error) : ''
return (
<div className="h-full grid place-items-center px-8 py-16">
<div className="text-center max-w-md">
<DownloadCloud className="mx-auto h-10 w-10 text-muted" strokeWidth={1.5} />
<h2 className="mt-4 text-base font-medium text-foreground">{title}</h2>
<p className="mt-2 text-sm text-secondary leading-relaxed">{hint}</p>
<button
onClick={onFetch}
disabled={isLoading}
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:brightness-110 disabled:opacity-60"
>
{isLoading ? (
<><RefreshCw className="h-4 w-4 animate-spin" /> ...</>
) : (
<><DownloadCloud className="h-4 w-4" /> </>
)}
</button>
{errMsg && (
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-bear">
<AlertCircle className="h-3.5 w-3.5" /> {errMsg}
</p>
)}
</div>
</div>
)
}
@@ -8,7 +8,7 @@ import type { PipelineJob } from '@/lib/api'
export const STAGE_LABELS: Record<string, string> = {
init: '初始化',
resolve_universe: '解析标的池',
sync_instruments: '同步标的维表',
sync_instruments: '同步个股维表',
sync_daily: '同步日 K',
sync_adj: '同步除权因子',
compute_enriched: '计算技术指标',
@@ -0,0 +1,253 @@
import { useState } from 'react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Check, GripVertical } from 'lucide-react'
import { storage } from '@/lib/storage'
export type CardKey =
| 'instruments' | 'daily' | 'adj_factor' | 'enriched'
| 'index' | 'etf' | 'minute' | 'financials'
interface CardDef {
key: CardKey
label: string
desc: string
/** 档位能力不足时该卡片是否默认隐藏(减少干扰) */
defaultHiddenIfNoCap: boolean
/** 无条件默认隐藏(用户可在设置里手动开启) */
defaultHidden?: boolean
}
/** 数据画像卡片定义 —— 默认顺序即此数组顺序 */
export const DATA_CARD_DEFS: CardDef[] = [
{ key: 'instruments', label: '个股维表', desc: 'A 股股票元数据', defaultHiddenIfNoCap: false },
{ key: 'daily', label: '日 K', desc: 'A 股日K线数据', defaultHiddenIfNoCap: false },
{ key: 'adj_factor', label: '除权因子', desc: '复权计算因子', defaultHiddenIfNoCap: true },
{ key: 'enriched', label: 'Enriched', desc: '技术指标计算结果', defaultHiddenIfNoCap: false },
{ key: 'index', label: '指数', desc: '主要市场指数日K', defaultHiddenIfNoCap: false },
{ key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false, defaultHidden: true },
{ key: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true },
{ key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true },
]
const DEFAULT_ORDER = DATA_CARD_DEFS.map(d => d.key)
/** 恢复默认时显示的卡片数量(按默认顺序取前 N 张) */
const DEFAULT_VISIBLE_COUNT = 5
const CAP_KEY_MAP: Partial<Record<CardKey, string>> = {
adj_factor: 'adj_factor',
minute: 'kline.minute.batch',
financials: 'financial',
}
/**
* 读取卡片显隐状态。结合档位能力决定默认值:
* - 用户显式设置过 → 用设置值
* - 未设置 + defaultHidden → 隐藏(无条件默认隐藏)
* - 未设置 + defaultHiddenIfNoCap + 当前无能力 → 隐藏
* - 其他 → 显示
*/
export function getCardVisibility(
caps: Record<string, unknown> | undefined,
): Record<string, boolean> {
const has = (capKey: string) => !capKey || !!caps?.[capKey]
const override = storage.dataCardVisible.get({})
const result: Record<string, boolean> = {}
for (const def of DATA_CARD_DEFS) {
if (def.key in override) {
result[def.key] = override[def.key]
} else if (def.defaultHidden) {
result[def.key] = false
} else {
result[def.key] = def.defaultHiddenIfNoCap ? has(CAP_KEY_MAP[def.key] ?? '') : true
}
}
return result
}
/**
* 读取卡片显示顺序。
* - 用户拖拽设置过 → 用设置值(过滤掉已不存在的 key, 补齐新增的 key)
* - 未设置 → 用 DATA_CARD_DEFS 默认顺序
*/
export function getCardOrder(): CardKey[] {
const saved = storage.dataCardOrder.get([])
if (!saved.length) return [...DEFAULT_ORDER]
const known = new Set<CardKey>(DEFAULT_ORDER)
const ordered = saved.filter(k => known.has(k as CardKey)) as CardKey[]
// 补齐新增的 key(默认顺序里新增的卡片追加到末尾)
for (const k of DEFAULT_ORDER) {
if (!ordered.includes(k)) ordered.push(k)
}
return ordered
}
export function PageSettingsModal({
caps,
}: {
caps: Record<string, unknown> | undefined
}) {
const [visible, setVisible] = useState<Record<string, boolean>>(() => getCardVisibility(caps))
const [order, setOrder] = useState<CardKey[]>(() => getCardOrder())
const persistVisible = (next: Record<string, boolean>) => {
setVisible(next)
storage.dataCardVisible.set(next)
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
}
const persistOrder = (next: CardKey[]) => {
setOrder(next)
storage.dataCardOrder.set(next)
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
}
const toggle = (key: CardKey) => persistVisible({ ...visible, [key]: !(visible[key] ?? true) })
const reset = () => {
// 恢复默认: 默认顺序 + 仅勾选前 5 张卡片, 其余隐藏
const defaultOrder = [...DEFAULT_ORDER]
const defaultVisible: Record<string, boolean> = {}
defaultOrder.forEach((k, i) => { defaultVisible[k] = i < DEFAULT_VISIBLE_COUNT })
storage.dataCardVisible.set(defaultVisible)
storage.dataCardOrder.set(defaultOrder)
setVisible(defaultVisible)
setOrder(defaultOrder)
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
}
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIdx = order.indexOf(active.id as CardKey)
const newIdx = order.indexOf(over.id as CardKey)
if (oldIdx < 0 || newIdx < 0) return
persistOrder(arrayMove(order, oldIdx, newIdx))
}
// 按 order 排序卡片定义
const defByKey = new Map(DATA_CARD_DEFS.map(d => [d.key, d]))
const orderedDefs = order.map(k => defByKey.get(k)!).filter(Boolean)
return (
<div className="space-y-2.5">
<p className="text-xs text-secondary leading-relaxed">
,,
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={order} strategy={verticalListSortingStrategy}>
<div className="space-y-1.5">
{orderedDefs.map((def) => {
const on = visible[def.key] ?? true
return (
<SortableCardRow
key={def.key}
id={def.key}
label={def.label}
desc={def.desc}
on={on}
onToggle={() => toggle(def.key)}
/>
)
})}
</div>
</SortableContext>
</DndContext>
<div className="flex items-center justify-end pt-1">
<button
onClick={reset}
className="px-2 py-0.5 rounded-btn text-[10px] text-secondary hover:text-foreground transition-colors"
>
</button>
</div>
</div>
)
}
// ── 可拖拽的卡片行 ──
function SortableCardRow({
id, label, desc, on, onToggle,
}: {
id: CardKey
label: string
desc: string
on: boolean
onToggle: () => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 10 : undefined,
}
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2 rounded-card border px-3 py-2 transition-colors ${
isDragging ? 'bg-elevated shadow-lg' : ''
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30'}`}
>
{/* 拖拽手柄 */}
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors shrink-0"
title="拖动排序"
>
<GripVertical className="h-4 w-4" />
</button>
{/* 显隐勾选 */}
<button
type="button"
onClick={onToggle}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
on ? 'bg-accent border-accent' : 'bg-base border-border'
}`}
role="checkbox"
aria-checked={on}
>
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</button>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-foreground">{label}</div>
<div className="text-[10px] text-muted leading-snug">{desc}</div>
</div>
</div>
)
}
@@ -0,0 +1,86 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
type PullKey = 'pipeline_pull_a_share' | 'pipeline_pull_etf' | 'pipeline_pull_index'
interface ScopeItem {
key: PullKey
label: string
desc: string
defaultOn: boolean
}
const ITEMS: ScopeItem[] = [
{ key: 'pipeline_pull_a_share', label: 'A股', desc: '沪深京 A 股日K(约 5500 只)', defaultOn: true },
{ key: 'pipeline_pull_index', label: '指数', desc: '主要市场指数(默认全量约 600 只)', defaultOn: true },
{ key: 'pipeline_pull_etf', label: 'ETF', desc: '场内交易基金(约 1500 只,首次较慢)', defaultOn: false },
]
export function PipelineScopeConfig() {
const qc = useQueryClient()
const prefs = useQuery({ queryKey: QK.preferences, queryFn: api.preferences })
const updateToggle = useMutation({
mutationFn: (cfg: Partial<Record<PullKey, boolean>>) => api.updatePipelinePullTypes(cfg),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.dataStatus })
},
})
const getValue = (key: PullKey, def: boolean) => prefs.data?.[key] ?? def
return (
<div className="space-y-2.5">
<p className="text-xs text-secondary leading-relaxed">
,
</p>
<div className="space-y-1.5">
{ITEMS.map((item) => {
const locked = item.key === 'pipeline_pull_a_share'
const on = locked || getValue(item.key, item.defaultOn)
return (
<div key={item.key}>
<label
className={`flex items-start gap-2.5 rounded-card border px-3 py-2.5 transition-colors ${
locked ? 'cursor-default' : 'cursor-pointer'
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30 hover:border-border/70'}`}
>
<button
type="button"
onClick={() => {
if (!locked) updateToggle.mutate({ [item.key]: !on } as never)
}}
disabled={locked || updateToggle.isPending}
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
on ? 'bg-accent border-accent' : 'bg-base border-border'
} ${locked ? 'opacity-80' : ''}`}
role="checkbox"
aria-checked={on}
>
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-foreground">{item.label}</span>
</div>
<div className="text-[10px] text-muted leading-snug mt-0.5">{item.desc}</div>
</div>
</label>
</div>
)
})}
</div>
{updateToggle.isPending && (
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
</div>
)}
<div className="text-[10px] text-muted leading-relaxed pt-1">
,
</div>
</div>
)
}
@@ -4,7 +4,7 @@ import { api, type EnrichedField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
const TABLE_TITLES: Record<string, string> = {
instruments: '标的维表',
instruments: '个股维表',
daily: '日 K',
adj_factor: '除权因子',
enriched: 'Enriched',
@@ -12,6 +12,9 @@ const TABLE_TITLES: Record<string, string> = {
index_instruments: '指数维表',
index_daily: '指数日 K',
index_enriched: '指数 Enriched',
etf_instruments: 'ETF 维表',
etf_daily: 'ETF 日 K',
etf_enriched: 'ETF Enriched',
}
function categorize(name: string): string {
@@ -5,7 +5,7 @@ import { fmtDate } from '@/lib/format'
import { Skeleton } from './Skeleton'
// 卡片能力定义:capKey → 查 capability limitstierReq → 无权限时显示的档位要求
// capKey 为空串表示该数据在 free-api 服务器(无档/免费档)即可获取,无需付费能力门控。
// capKey 为空串表示该数据在 free-api 服务器(None 档/Free 档)即可获取,无需付费能力门控。
export const CARD_META: Record<string, {
capKey: string // 对应的 capability key,空串表示本地计算 / free 服务器可用
tierReq: string // 最低档位要求(无权限时显示)
@@ -15,6 +15,8 @@ export const CARD_META: Record<string, {
daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' },
adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' },
enriched: { capKey: '', tierReq: '' },
// ETF 复用日K批量能力(免费档 kline.daily.batch 即可),不显示档位徽章
etf: { capKey: 'kline.daily.batch', tierReq: '' },
minute: { capKey: 'kline.minute.batch', tierReq: 'Pro+' },
financials: { capKey: 'financial', tierReq: 'Expert' },
}
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Loader2, Search, RefreshCw, Check } from 'lucide-react'
import { Loader2, Search, Check, Clock, Zap, Settings2, AlertCircle, CheckCircle2, Calendar } from 'lucide-react'
import { api, type ExtDataConfig } from '@/lib/api'
import { toast } from '@/components/Toast'
export function ExtDataPullPanel({ config, onSaved }: {
config: ExtDataConfig
@@ -22,157 +23,284 @@ export function ExtDataPullPanel({ config, onSaved }: {
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [running, setRunning] = useState(false)
const [runResult, setRunResult] = useState<{ rows: number; date: string } | null>(null)
const [testResult, setTestResult] = useState<{ total_rows: number; preview: Record<string, unknown>[]; has_symbol: boolean } | null>(null)
const [error, setError] = useState('')
const handleSave = () => {
let headers: Record<string, string> | undefined
if (headerStr.trim()) {
try { headers = JSON.parse(headerStr) }
catch { setError('Headers 不是有效 JSON'); return }
}
let field_map: Record<string, string> | undefined
if (fieldMapStr.trim()) {
try { field_map = JSON.parse(fieldMapStr) }
catch { setError('字段映射不是有效 JSON'); return }
}
setSaving(true); setError('')
api.extDataPullConfig(config.id, {
// 解析 JSON 输入, 失败时设置 error 并返回 null
const parseJson = (str: string, label: string): Record<string, string> | undefined | null => {
if (!str.trim()) return undefined
try { return JSON.parse(str) }
catch { setError(`${label} 不是有效 JSON`); return null }
}
// 构建保存 payload (复用当前编辑态), enabledOverride 用于开关自动保存
const buildPayload = (enabledOverride?: boolean) => {
const headers = parseJson(headerStr, 'Headers')
if (headers === null) return null
const field_map = parseJson(fieldMapStr, '字段映射')
if (field_map === null) return null
return {
url, method, headers, body: body || undefined,
response_path: responsePath, field_map,
schedule_minutes: schedule, enabled,
}).then(() => onSaved())
schedule_minutes: schedule, enabled: enabledOverride ?? enabled,
}
}
const handleSave = (silent = false) => {
const payload = buildPayload()
if (!payload) return
setSaving(true); setError('')
api.extDataPullConfig(config.id, payload)
.then(() => {
onSaved()
if (!silent) toast('配置已保存', 'success')
})
.catch(e => setError(e.message || '保存失败'))
.finally(() => setSaving(false))
}
const handleTest = () => {
setTesting(true); setError(''); setTestResult(null)
let headers: Record<string, string> | undefined
if (headerStr.trim()) {
try { headers = JSON.parse(headerStr) }
catch { setError('Headers 不是有效 JSON'); setTesting(false); return }
}
let field_map: Record<string, string> | undefined
if (fieldMapStr.trim()) {
try { field_map = JSON.parse(fieldMapStr) }
catch { setError('字段映射不是有效 JSON'); setTesting(false); return }
}
api.extDataPullConfig(config.id, {
url, method, headers, body: body || undefined,
response_path: responsePath, field_map,
schedule_minutes: schedule, enabled,
}).then(() => api.extDataPullTest(config.id))
const payload = buildPayload()
if (!payload) { setTesting(false); return }
api.extDataPullConfig(config.id, payload)
.then(() => api.extDataPullTest(config.id))
.then(r => { setTestResult(r); onSaved() })
.catch(e => setError(e.message || '测试失败'))
.finally(() => setTesting(false))
}
const handleRun = () => {
setRunning(true); setError('')
setRunning(true); setError(''); setRunResult(null)
api.extDataPullRun(config.id)
.then(() => onSaved())
.then(r => {
setRunResult({ rows: r.rows, date: r.date })
onSaved()
toast(`拉取成功 · ${r.rows}`, 'success')
})
.catch(e => setError(e.message || '执行失败'))
.finally(() => setRunning(false))
}
// 开关 toggle: 自动保存全量配置 (切换 enabled), 后端 refresh 后立即首次拉取
const [toggling, setToggling] = useState(false)
const handleToggle = (next: boolean) => {
if (toggling) return
if (next && !url.trim()) {
toast('请先填写拉取 URL', 'error')
return
}
const payload = buildPayload(next)
if (!payload) return
setToggling(true); setError(''); setEnabled(next)
api.extDataPullConfig(config.id, payload)
.then(() => {
onSaved()
toast(next ? '定时拉取已启用 · 立即执行首次拉取' : '定时拉取已关闭', 'success')
})
.catch(e => {
setEnabled(!next) // 回滚
setError(e.message || '切换失败')
})
.finally(() => setToggling(false))
}
// 格式化时间显示
const fmtTime = (iso: string | null | undefined) => {
if (!iso) return null
const d = new Date(iso)
if (isNaN(d.getTime())) return null
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}`
}
return (
<div className="space-y-2.5">
<div className="flex gap-1.5">
<select
value={method} onChange={e => setMethod(e.target.value)}
className="shrink-0 rounded-md border border-border bg-elevated px-2 py-1.5 text-[11px] text-foreground"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
<input
value={url} onChange={e => setUrl(e.target.value)}
placeholder="https://api.example.com/data"
className="flex-1 min-w-0 rounded-md border border-border bg-elevated px-2.5 py-1.5 text-[11px] font-mono text-foreground placeholder:text-muted/50"
/>
</div>
<div className="space-y-3">
{/* ===== 分区 ①: 请求配置 ===== */}
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-secondary">
<Settings2 className="h-3 w-3 text-muted" />
<span></span>
</div>
<div>
<div className="text-[10px] text-muted mb-0.5">Headers (JSON)</div>
<textarea
value={headerStr} onChange={e => setHeaderStr(e.target.value)}
placeholder='{"Authorization": "Bearer xxx"}'
rows={2}
className="w-full rounded-md border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
<div className="flex gap-1.5">
<select
value={method} onChange={e => setMethod(e.target.value)}
className="shrink-0 rounded-btn border border-border bg-elevated px-2 py-1.5 text-[11px] text-foreground"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
<input
value={url} onChange={e => setUrl(e.target.value)}
placeholder="https://api.example.com/data"
className="flex-1 min-w-0 rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[11px] font-mono text-foreground placeholder:text-muted/50"
/>
</div>
{method === 'POST' && (
<div>
<div className="text-[10px] text-muted mb-0.5"> (JSON)</div>
<div className="text-[10px] text-muted mb-1">Headers (JSON)</div>
<textarea
value={body} onChange={e => setBody(e.target.value)}
placeholder='{"page": 1}'
value={headerStr} onChange={e => setHeaderStr(e.target.value)}
placeholder='{"Authorization": "Bearer xxx"}'
rows={2}
className="w-full rounded-md border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[10px] text-muted mb-0.5"></div>
<input
value={responsePath} onChange={e => setResponsePath(e.target.value)}
placeholder="data.list"
className="w-full rounded-md border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40"
/>
{method === 'POST' && (
<div>
<div className="text-[10px] text-muted mb-1"> (JSON)</div>
<textarea
value={body} onChange={e => setBody(e.target.value)}
placeholder='{"page": 1}'
rows={2}
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[10px] text-muted mb-1"></div>
<input
value={responsePath} onChange={e => setResponsePath(e.target.value)}
placeholder="data.list"
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40"
/>
</div>
<div>
<div className="text-[10px] text-muted mb-1"> ()</div>
<input
type="number" min={1} value={schedule} onChange={e => setSchedule(Number(e.target.value))}
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground"
/>
</div>
</div>
<div>
<div className="text-[10px] text-muted mb-0.5"> ()</div>
<input
type="number" min={1} value={schedule} onChange={e => setSchedule(Number(e.target.value))}
className="w-full rounded-md border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground"
<div className="text-[10px] text-muted mb-1"> ( JSON)</div>
<textarea
value={fieldMapStr} onChange={e => setFieldMapStr(e.target.value)}
placeholder='{"code": "symbol", "val": "score"}'
rows={2}
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
</div>
<div>
<div className="text-[10px] text-muted mb-0.5"> ( JSON)</div>
<textarea
value={fieldMapStr} onChange={e => setFieldMapStr(e.target.value)}
placeholder='{"code": "symbol", "val": "score"}'
rows={2}
className="w-full rounded-md border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
{/* ===== 分区 ②: 定时拉取状态 ===== */}
<div className="rounded-card border border-border/60 bg-elevated/30 p-2.5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-secondary">
<Clock className="h-3 w-3 text-muted" />
<span></span>
</div>
{/* 自定义 Toggle 开关 */}
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={toggling}
onClick={() => handleToggle(!enabled)}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors duration-200 disabled:opacity-50 ${
enabled ? 'bg-accent' : 'bg-border'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform duration-200 ${
enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{/* 状态文案 */}
<div className="text-[10px] leading-relaxed">
{enabled ? (
<div className="space-y-0.5">
<div className="flex items-center gap-1 text-accent">
<span className="h-1 w-1 rounded-full bg-accent animate-pulse" />
<span> · {schedule} </span>
</div>
{pull?.next_run && fmtTime(pull.next_run) && (
<div className="flex items-center gap-1 text-muted">
<Calendar className="h-2.5 w-2.5" />
<span>{fmtTime(pull.next_run)}</span>
</div>
)}
</div>
) : (
<div className="text-muted"> · </div>
)}
</div>
{/* 上次执行结果 */}
{pull?.last_run && (
<div className="flex items-start gap-1.5 pt-1.5 border-t border-border/40">
{pull.last_status === 'success' ? (
<CheckCircle2 className="h-3 w-3 text-emerald-500 shrink-0 mt-px" />
) : (
<AlertCircle className="h-3 w-3 text-danger shrink-0 mt-px" />
)}
<div className="min-w-0 flex-1">
<div className={`text-[10px] font-medium ${pull.last_status === 'success' ? 'text-emerald-500' : 'text-danger'}`}>
{pull.last_message || (pull.last_status === 'success' ? '成功' : '失败')}
</div>
{fmtTime(pull.last_run) && (
<div className="text-[9px] text-muted">{fmtTime(pull.last_run)}</div>
)}
</div>
</div>
)}
</div>
<div className="flex items-center justify-between">
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)}
className="rounded border-border accent-accent"
/>
<span className="text-[10px] text-secondary"></span>
</label>
<div className="flex items-center gap-1.5">
{/* ===== 分区 ③: 操作按钮 ===== */}
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleTest}
disabled={testing || !url}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn border border-border bg-elevated text-[10px] text-foreground hover:bg-border/30 disabled:opacity-40 transition-colors"
className="inline-flex items-center justify-center gap-1 px-2 py-2 rounded-btn border border-border bg-elevated text-xs text-foreground hover:bg-border/30 disabled:opacity-40 transition-colors"
>
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Search className="h-3 w-3" />}
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
</button>
<button
onClick={handleRun}
disabled={running || !url}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn bg-accent/90 text-base text-[10px] font-medium hover:bg-accent disabled:opacity-40 transition-colors"
className="inline-flex items-center justify-center gap-1 px-2 py-2 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
>
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Zap className="h-3.5 w-3.5" />}
</button>
</div>
<button
onClick={() => handleSave(false)}
disabled={saving || !url}
className="w-full inline-flex items-center justify-center gap-1 py-2 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</button>
</div>
{/* ===== 结果展示 ===== */}
{runResult && (
<div className="rounded-card border border-emerald-500/30 bg-emerald-500/[0.06] p-2.5 flex items-center justify-between text-[10px]">
<span className="text-emerald-500 font-medium flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
</span>
<span className="text-secondary">{runResult.rows} · {runResult.date}</span>
</div>
)}
{testResult && (
<div className="rounded-md border border-accent/30 bg-accent/[0.04] p-2.5 space-y-1.5">
<div className="rounded-card border border-accent/30 bg-accent/[0.04] p-2.5 space-y-1.5">
<div className="flex items-center justify-between text-[10px]">
<span className="text-accent font-medium"></span>
<span className="text-secondary">{testResult.total_rows} </span>
@@ -188,25 +316,11 @@ export function ExtDataPullPanel({ config, onSaved }: {
</div>
)}
{pull?.last_run && (
<div className="flex items-center justify-between text-[10px] border-t border-border/50 pt-2">
<span className="text-muted"></span>
<span className={pull.last_status === 'success' ? 'text-green-500' : 'text-danger'}>
{pull.last_message}
</span>
{error && (
<div className="text-[10px] text-danger text-center bg-danger/[0.06] rounded-btn py-1.5">
{error}
</div>
)}
<button
onClick={handleSave}
disabled={saving || !url}
className="w-full inline-flex items-center justify-center gap-1 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</button>
{error && <div className="text-[10px] text-danger text-center">{error}</div>}
</div>
)
}
@@ -0,0 +1,266 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Sparkles, Loader2, AlertTriangle, Copy, Check, RefreshCw,
Database, Settings2, Send, Wand2, Minimize2, History,
} from 'lucide-react'
import { cn } from '@/lib/cn'
import { MarkdownRenderer } from './MarkdownRenderer'
import {
type ActiveTask, type HistoryReport,
minimizeDialog, closeDialog, startAnalysis,
} from '@/lib/aiReportStore'
interface Props {
/** 当前展示的任务;活跃任务或历史报告 */
task: ActiveTask | HistoryReport | null
mode: 'active' | 'history' | null
minimized: boolean
}
type Phase = 'loading' | 'streaming' | 'done' | 'error'
// 统一字段读取:活跃任务有 phase/createdAt,历史报告没有(按 done 处理)
function getPhase(task: ActiveTask | HistoryReport | null): Phase {
if (!task) return 'loading'
if ('phase' in task) return task.phase
return 'done' // 历史报告视为已完成
}
function getContent(task: ActiveTask | HistoryReport | null): string {
return task?.content ?? ''
}
function getMeta(task: ActiveTask | HistoryReport | null) {
if (!task) return null
if ('meta' in task) return task.meta
// 历史报告
return { summary: task.summary, periods: task.periods }
}
export function AiAnalysisDialog({ task, mode, minimized }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const focusInputRef = useRef<HTMLInputElement>(null)
const [focus, setFocus] = useState('')
const [copied, setCopied] = useState(false)
const phase = getPhase(task)
const content = getContent(task)
const meta = getMeta(task)
const isHistory = mode === 'history'
const isWorking = phase === 'loading' || phase === 'streaming'
const open = !!task && !minimized
// 流式时自动滚动到底部
useEffect(() => {
if (open && phase === 'streaming' && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [content, phase, open])
// 切换任务时回填 focus
useEffect(() => {
setFocus(task && 'focus' in task ? task.focus : '')
}, [task])
const handleStartNew = useCallback(async () => {
if (!task) return
const name = 'name' in task ? task.name : ''
await startAnalysis(task.symbol, name, focus.trim())
}, [task, focus])
const handleCopy = async () => {
if (!content) return
try {
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { /* ignore */ }
}
if (!open) return null
const error = task && 'error' in task ? task.error : ''
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* ===== 头部 ===== */}
<div className="relative px-5 py-3.5 border-b border-border/50 bg-gradient-to-r from-purple-500/[0.06] via-fuchsia-500/[0.04] to-transparent">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 shrink-0">
{isHistory
? <History className="h-4.5 w-4.5 text-purple-300" />
: <Sparkles className="h-4.5 w-4.5 text-purple-300" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground truncate">
{isHistory ? '历史分析报告' : 'AI 财务分析'}
</span>
{task && <span className="text-xs text-secondary truncate">{task.name}</span>}
{task && <span className="text-[10px] font-mono text-muted shrink-0">{task.symbol}</span>}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted">
{meta?.summary ? (
<span className="flex items-center gap-1 truncate">
<Database className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{meta.summary}</span>
</span>
) : isWorking ? <span></span> : null}
{phase === 'streaming' && (
<span className="flex items-center gap-1 text-purple-300 shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-purple-400 animate-pulse" />
</span>
)}
{isHistory && task && 'created_at' in task && (
<span className="shrink-0">{fmtRelative(task.created_at)}</span>
)}
</div>
</div>
{/* 右侧操作按钮 */}
<div className="flex items-center gap-1 shrink-0">
{/* 复制:仅在内容就绪且非生成中显示 */}
{content && !isWorking && (
<button onClick={handleCopy} title="复制全文"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
)}
{/* 生成中:仅最小化(后台继续生成),无关闭按钮 */}
{!isHistory && isWorking && (
<button onClick={minimizeDialog} title="最小化为气泡,后台继续生成"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<Minimize2 className="h-4 w-4" />
</button>
)}
{/* 完成态/历史报告:显示关闭按钮 */}
{(!isWorking || isHistory) && (
<button onClick={closeDialog} title="关闭"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
{/* ===== 内容区 ===== */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 min-h-[280px]">
{/* 加载态 */}
{phase === 'loading' && !content && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="relative">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 flex items-center justify-center">
<Sparkles className="h-4.5 w-4.5 text-purple-300 animate-pulse" />
</div>
<Loader2 className="absolute -inset-1 h-12 w-12 text-purple-400/40 animate-spin" style={{ animationDuration: '3s' }} />
</div>
<div className="text-xs text-secondary">AI </div>
<div className="text-[10px] text-muted"> / / / ,</div>
</div>
)}
{/* 错误态 */}
{phase === 'error' && (
<div className="flex flex-col items-center justify-center py-14 gap-3">
<div className="h-11 w-11 rounded-full bg-danger/10 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="text-sm font-medium text-foreground"></div>
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
{error.includes('AI') && (
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
<Settings2 className="h-3.5 w-3.5" /> AI
</button>
)}
<button onClick={handleStartNew}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-purple-500/15 border border-purple-400/30 text-xs text-purple-300 hover:bg-purple-500/20 transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* 报告内容 */}
{(content || phase === 'streaming') && (
<div className="relative">
<MarkdownRenderer content={content} />
{phase === 'streaming' && (
<span className="inline-block w-1.5 h-3.5 bg-purple-400 ml-0.5 align-middle animate-pulse rounded-sm" />
)}
</div>
)}
</div>
{/* ===== 底部:自定义关注点输入 ===== */}
<div className="border-t border-border/50 bg-surface/60 px-5 py-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted shrink-0">
<Wand2 className="h-3 w-3" />
<span className="hidden sm:inline"></span>
</div>
<input
ref={focusInputRef}
type="text"
value={focus}
onChange={e => setFocus(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (phase === 'done' || phase === 'error' || isHistory)) handleStartNew() }}
disabled={isWorking}
placeholder={isHistory ? '修改关注重点,回车重新生成' : (phase === 'done' ? '如:重点看债务风险…回车重新分析' : '可留空,留空则全面分析')}
className={cn(
'flex-1 h-8 px-3 rounded-lg bg-base ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/40',
'focus:outline-none focus:ring-2 focus:ring-purple-400/30 transition-shadow disabled:opacity-50',
)}
/>
{isHistory ? (
<button
onClick={handleStartNew}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 text-xs font-medium text-purple-300 hover:from-purple-500/30 hover:to-fuchsia-500/20 transition-all shrink-0"
title="以此关注点重新生成新报告"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleStartNew}
disabled={isWorking}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 text-xs font-medium text-purple-300 hover:from-purple-500/30 hover:to-fuchsia-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0"
title={focus.trim() ? '按关注重点重新分析' : '重新分析'}
>
{isWorking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : phase === 'done' ? <RefreshCw className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
{phase === 'done' ? '重新分析' : '分析'}
</button>
)}
</div>
<p className="mt-1.5 text-[10px] text-muted/50 leading-relaxed">
{isHistory
? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
: '报告由项目已配置的 AI 模型基于本地财务数据生成;可在输入框追加关注点后重新生成。报告仅供参考,不构成投资建议。'}
</p>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
// ===== 小工具 =====
function fmtRelative(iso: string): string {
try {
const t = new Date(iso).getTime()
const diff = Date.now() - t
if (diff < 60_000) return '刚刚'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
return new Date(iso).toLocaleDateString('zh-CN')
} catch { return '' }
}
@@ -0,0 +1,14 @@
import { useDialogTask, useDialogState } from '@/lib/aiReportStore'
import { AiAnalysisDialog } from './AiAnalysisDialog'
/**
* AI 分析对话框宿主 —— 单点挂载在 Layout。
*
* 从 store 读取当前对话框状态(任务 + 最小化),把 AiAnalysisDialog 作为纯视图渲染。
* 一次挂载,全局生效:任意页面发起的分析都会显示在这个对话框里。
*/
export function AiAnalysisHost() {
const { task, mode } = useDialogTask()
const { minimized } = useDialogState()
return <AiAnalysisDialog task={task} mode={mode} minimized={minimized} />
}
@@ -0,0 +1,254 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Loader2, Check, AlertCircle } from 'lucide-react'
import { useActiveTasks, restoreDialog } from '@/lib/aiReportStore'
import type { ActiveTask } from '@/lib/aiReportStore'
/**
* AI 分析任务全局气泡容器 —— 玻璃拟态卡片,挂在网页右侧。
*
* 拖拽丝滑的关键(60fps):
* - 位置用 transform: translate3d 存储(走 GPU 合成层,不触发 layout/paint)
* - 拖动期间直接操作 DOM.style.transform,完全绕开 React setState 重渲染
* - 拖动结束才同步一次 state + 持久化 localStorage
* - 拖动时给容器加 .dragging 类,禁用所有 transition,消除回弹延迟
*
* 视觉:
* - 玻璃拟态(frosted glass):半透明 + backdrop-blur + 细边框 + 内发光
* - 固定宽度,内容居中,多任务竖向堆叠
* - 生成中:柔和呼吸光环(非刺眼 ping)
* - hover:展开操作区,带平滑过渡
*/
const BUBBLE_W = 148 // 卡片固定宽度(紧凑单行版)
const EDGE_MARGIN = 12 // 距视口边缘最小间距
export function AiReportBubble() {
const activeTasks = useActiveTasks()
const containerRef = useRef<HTMLDivElement>(null)
// pos 只在拖拽结束时更新一次(用于初始化/持久化),拖拽过程不触发它
const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos())
// ===== 拖拽(纯 DOM 操作,60fps) =====
const draggingRef = useRef(false)
const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 }) // 鼠标起点 + 元素起点
const movedRef = useRef(false) // 本次是否真的移动了(区分点击)
// 记录 pointerdown 时命中的卡片回调(松手时若未拖动则触发它 = 点击)
const clickTargetRef = useRef<(() => void) | null>(null)
const applyTransform = useCallback((x: number, y: number) => {
const el = containerRef.current
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)`
}, [])
const clamp = useCallback((x: number, y: number) => {
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
const maxY = window.innerHeight - 80
return {
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
}
}, [])
const onPointerDown = useCallback((e: React.PointerEvent) => {
draggingRef.current = true
movedRef.current = false
dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y }
const el = containerRef.current
if (el) el.classList.add('dragging')
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}, [pos.x, pos.y])
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!draggingRef.current) return
const dx = e.clientX - dragData.current.mx
const dy = e.clientY - dragData.current.my
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
const nx = dragData.current.ox + dx
const ny = dragData.current.oy + dy
const c = clamp(nx, ny)
applyTransform(c.x, c.y) // ← 直接改 DOM,不走 React,丝滑
}, [clamp, applyTransform])
const onPointerUp = useCallback(() => {
if (!draggingRef.current) return
draggingRef.current = false
const el = containerRef.current
if (el) el.classList.remove('dragging')
if (movedRef.current) {
// 拖动结束 → 持久化位置
setPos(prev => {
const transform = el?.style.transform ?? ''
const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/)
const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev
savePos(finalPos)
return finalPos
})
} else {
// 未移动 → 视为点击,触发卡片回调
const fn = clickTargetRef.current
clickTargetRef.current = null
fn?.()
}
}, [])
// 窗口尺寸变化时确保不越界
useEffect(() => {
const onResize = () => {
setPos(prev => {
const c = clamp(prev.x, prev.y)
if (c.x !== prev.x || c.y !== prev.y) {
applyTransform(c.x, c.y)
return c
}
return prev
})
}
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [clamp, applyTransform])
// 初始化 transform(pos 变化时同步,如 resize / 首次挂载)
useEffect(() => {
applyTransform(pos.x, pos.y)
}, [pos.x, pos.y, applyTransform])
if (activeTasks.length === 0) return null
return (
<div
ref={containerRef}
className="ai-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
style={{
width: `${BUBBLE_W}px`,
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
touchAction: 'none',
// 拖动时禁用过渡(通过 .dragging 类控制);静止时用 transition 让 resize/吸附有动画
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<AnimatePresence mode="popLayout">
{activeTasks.map((task, i) => (
<BubbleItem
key={task.id}
task={task}
isLast={i === activeTasks.length - 1}
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
/>
))}
</AnimatePresence>
{/* 内联样式:拖动时禁用过渡,确保 1:1 跟手 */}
<style>{`
.ai-bubble-root.dragging { transition: none !important; }
`}</style>
</div>
)
}
// ===== 单个胶囊卡片(紧凑玻璃拟态) =====
function BubbleItem({ task, isLast, onPointerDown }: {
task: ActiveTask
isLast: boolean
onPointerDown: () => void
}) {
const isWorking = task.phase === 'loading' || task.phase === 'streaming'
const isError = task.phase === 'error'
// 状态配色
const accent = isWorking
? 'from-purple-500/25 to-fuchsia-500/20 text-purple-300 border-purple-300/40 shadow-[0_6px_24px_-10px_rgba(168,85,247,0.5)]'
: isError
? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]'
: 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]'
return (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -8 }}
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
className={isLast ? '' : 'mb-1.5'}
>
<div
onPointerDown={onPointerDown}
role="button"
tabIndex={0}
title={isWorking ? '生成中,点击恢复对话框' : isError ? '分析失败,点击重试' : '点击查看报告'}
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
>
{/* 生成中:顶部进度流光 */}
{isWorking && (
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-purple-200 to-transparent animate-bubble-progress" />
</div>
)}
{/* 状态图标 */}
<span className="flex h-4 w-4 items-center justify-center shrink-0">
{isWorking ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : isError ? (
<AlertCircle className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
</span>
{/* 标的名(单行) */}
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
{task.name || task.symbol}
</span>
{/* 状态后缀 */}
<span className="shrink-0 text-[9px] leading-none">
{isWorking ? (
<span className="text-purple-300/80"></span>
) : isError ? (
<span className="text-red-300/80"></span>
) : (
<span className="text-emerald-300/80"></span>
)}
</span>
</div>
{/* 内联关键帧:进度条流动 */}
<style>{`
@keyframes bubble-progress {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
.animate-bubble-progress { animation: bubble-progress 1.6s ease-in-out infinite; }
`}</style>
</motion.div>
)
}
// ===== 位置持久化 =====
const POS_KEY = 'ai_bubble_pos'
function loadPos(): { x: number; y: number } {
// 默认:右下角(距右边缘 EDGE_MARGIN,距底部留出空间避开右下角元素)
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 200)
try {
const v = localStorage.getItem(POS_KEY)
if (v) {
const p = JSON.parse(v)
if (typeof p.x === 'number' && typeof p.y === 'number') {
// 钳制到当前视口(防止保存的位置在缩小后的窗口外)
return {
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
}
}
}
} catch { /* ignore */ }
return { x: defaultX, y: defaultY }
}
function savePos(p: { x: number; y: number }) {
try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ }
}
@@ -0,0 +1,225 @@
import { Fragment, type ReactNode } from 'react'
/**
* 轻量 Markdown 渲染器 — 零依赖,专为 AI 财务分析报告设计。
*
* 支持的语法(AI 财务分析提示词约束的子集,足够用):
* - 标题 # ## ### ####
* - 加粗 **text**
* - 行内代码 `code`
* - 无序列表 - / *
* - 有序列表 1.
* - 表格 | a | b |
* - 引用 >
* - 分隔线 --- / ***
* - 段落
*
* 不追求完整 GFM,只覆盖 AI 报告会产出的结构。
*/
// ===== 行内格式:加粗 / 行内代码 / 星号评级 =====
function renderInline(text: string, keyBase: string): ReactNode[] {
const nodes: ReactNode[] = []
// 正则:匹配 **加粗** 或 `代码` 或 ★ 评级
const re = /(\*\*([^*]+)\*\*)|(`([^`]+)`)/g
let last = 0
let m: RegExpExecArray | null
let i = 0
while ((m = re.exec(text)) !== null) {
if (m.index > last) nodes.push(<Fragment key={`${keyBase}-t-${i}`}>{text.slice(last, m.index)}</Fragment>)
if (m[1]) {
// 加粗
nodes.push(<strong key={`${keyBase}-b-${i}`} className="font-semibold text-foreground">{m[2]}</strong>)
} else if (m[3]) {
// 行内代码
nodes.push(
<code key={`${keyBase}-c-${i}`} className="px-1 py-0.5 rounded bg-elevated text-[0.85em] font-mono text-accent">
{m[4]}
</code>,
)
}
last = m.index + m[0].length
i++
}
if (last < text.length) nodes.push(<Fragment key={`${keyBase}-t-end`}>{text.slice(last)}</Fragment>)
return nodes
}
// ===== 表格解析 =====
function parseTable(lines: string[], start: number): { rows: string[][]; consumed: number } | null {
// 找到连续的表格行(以 | 开头)
const tableLines: string[] = []
let idx = start
while (idx < lines.length && lines[idx].trim().startsWith('|')) {
tableLines.push(lines[idx].trim())
idx++
}
if (tableLines.length < 2) return null
// 第二行必须是分隔行 |---|---|
if (!/^|[\s-:|]+$/.test(tableLines[1]) && !tableLines[1].split('|').every(c => /^[\s-:]*$/.test(c))) {
return null
}
const parseRow = (line: string) =>
line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
const header = parseRow(tableLines[0])
const body = tableLines.slice(2).map(parseRow)
return { rows: [header, ...body], consumed: tableLines.length }
}
// ===== 主渲染 =====
export function MarkdownRenderer({ content }: { content: string }) {
const lines = content.replace(/\r\n/g, '\n').split('\n')
const blocks: ReactNode[] = []
let i = 0
let key = 0
while (i < lines.length) {
const line = lines[i]
const trimmed = line.trim()
// 空行
if (!trimmed) {
i++
continue
}
// 分隔线
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
blocks.push(<hr key={key++} className="my-6 border-border" />)
i++
continue
}
// 标题
const hMatch = trimmed.match(/^(#{1,4})\s+(.+)$/)
if (hMatch) {
const level = hMatch[1].length
const text = hMatch[2]
const sizeCls = level === 1 ? 'text-base' : level === 2 ? 'text-sm' : 'text-xs'
const mtCls = level <= 2 ? 'mt-6' : 'mt-5'
blocks.push(
<div key={key++} className={`${sizeCls} ${mtCls} mb-3 font-semibold text-foreground flex items-center gap-1.5`}>
{renderInline(text, `h-${key}`)}
</div>,
)
i++
continue
}
// 引用
if (trimmed.startsWith('>')) {
const quoteLines: string[] = []
while (i < lines.length && lines[i].trim().startsWith('>')) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''))
i++
}
blocks.push(
<blockquote key={key++} className="my-4 pl-3 border-l-2 border-amber-400/40 bg-amber-400/[0.04] py-1.5 pr-2 rounded-r text-xs text-secondary">
{renderInline(quoteLines.join(' '), `q-${key}`)}
</blockquote>,
)
continue
}
// 表格
if (trimmed.startsWith('|')) {
const table = parseTable(lines, i)
if (table) {
const [header, ...body] = table.rows
const ncol = header.length
blocks.push(
<div key={key++} className="my-5 overflow-hidden rounded-btn border border-border/30">
<table className="w-full text-xs border-collapse table-fixed">
<colgroup>
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */}
<col className="w-auto" />
{Array.from({ length: ncol - 1 }).map((_, ci) => (
<col key={ci} className={ci === ncol - 2 ? 'w-1/2' : 'w-auto'} />
))}
</colgroup>
<thead>
<tr className="bg-elevated/50">
{header.map((cell, ci) => (
<th key={ci} className="px-2.5 py-1.5 text-left font-medium text-foreground border-b border-border/40 whitespace-nowrap">
{renderInline(cell, `th-${key}-${ci}`)}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, ri) => (
<tr key={ri} className="border-b border-border/20 last:border-0 hover:bg-elevated/20">
{row.map((cell, ci) => (
<td key={ci} className="px-2.5 py-1.5 text-foreground align-top break-words">
{renderInline(cell, `td-${key}-${ri}-${ci}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>,
)
i += table.consumed
continue
}
}
// 无序列表
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = []
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*]\s+/, ''))
i++
}
blocks.push(
<ul key={key++} className="my-4 space-y-2">
{items.map((item, ii) => (
<li key={ii} className="flex items-start gap-2 text-sm text-foreground leading-relaxed">
<span className="mt-[7px] h-1 w-1 rounded-full bg-accent/60 shrink-0" />
<span>{renderInline(item, `li-${key}-${ii}`)}</span>
</li>
))}
</ul>,
)
continue
}
// 有序列表
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = []
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''))
i++
}
blocks.push(
<ol key={key++} className="my-4 space-y-2">
{items.map((item, ii) => (
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
<span className="mt-0.5 h-4 w-4 rounded-full bg-accent/10 text-accent text-[10px] font-mono flex items-center justify-center shrink-0">
{ii + 1}
</span>
<span className="flex-1 text-foreground">{renderInline(item, `ol-${key}-${ii}`)}</span>
</li>
))}
</ol>,
)
continue
}
// 普通段落
blocks.push(
<p key={key++} className="my-3 text-sm text-foreground leading-relaxed">
{renderInline(trimmed, `p-${key}`)}
</p>,
)
i++
}
// 注意:外层不加 space-y-*,否则会覆盖各块自己的 margin-top 导致间距失效。
// 让各块的 my-* 自然叠加(margin collapse),间距更可控。
return <div>{blocks}</div>
}
@@ -0,0 +1,125 @@
import { useEffect } from 'react'
import { History, Trash2, FileText, Clock, Sparkles, Loader2 } from 'lucide-react'
import { useHistoryReports, openHistoryReport, deleteReport, loadHistory } from '@/lib/aiReportStore'
import { useActiveTasks } from '@/lib/aiReportStore'
/**
* AI 财务分析历史报告面板 —— 显示在财务页底部。
*
* - 列出最近 20 条报告(后端裁剪)
* - 点击查看 → 打开到对话框(历史模式)
* - 显示正在生成中的对应标的(若该标的有活跃任务,标注)
* - 支持删除单条
*/
export function ReportHistoryPanel() {
const { reports, loaded } = useHistoryReports()
const activeTasks = useActiveTasks()
// 首次挂载拉取一次
useEffect(() => { loadHistory() }, [])
// 活跃任务的 symbol 集合(用于在历史列表里标注"生成中")
const activeSymbols = new Set(activeTasks.map(t => t.symbol))
if (!loaded) {
return (
<div className="rounded-card border border-border/40 bg-surface px-4 py-6 text-center">
<Loader2 />
</div>
)
}
if (reports.length === 0) {
return (
<div className="rounded-card border border-dashed border-border/50 bg-surface/50 px-6 py-8 text-center">
<History className="mx-auto h-6 w-6 text-muted/40" />
<div className="mt-2 text-xs text-muted"></div>
<div className="mt-0.5 text-[10px] text-muted/60">AI ,</div>
</div>
)
}
return (
<div className="rounded-card border border-border bg-surface overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border/50 bg-elevated/20">
<div className="flex items-center gap-2">
<History className="h-3.5 w-3.5 text-secondary" />
<span className="text-xs font-medium text-foreground"></span>
<span className="text-[10px] text-muted">{reports.length}/20</span>
</div>
<span className="text-[10px] text-muted/60"> · 20 </span>
</div>
{/* 列表 */}
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
{reports.map(r => {
const isGenerating = activeSymbols.has(r.symbol)
return (
<div
key={r.id}
className="group flex items-center gap-3 px-4 py-2.5 hover:bg-elevated/30 transition-colors cursor-pointer"
onClick={() => openHistoryReport(r.id)}
>
{/* 图标 */}
<div className={`flex h-8 w-8 items-center justify-center rounded-lg shrink-0 ${
isGenerating
? 'bg-purple-400/10 text-purple-300'
: 'bg-elevated text-secondary group-hover:text-accent'
}`}>
{isGenerating
? <Sparkles className="h-3.5 w-3.5 animate-pulse" />
: <FileText className="h-3.5 w-3.5" />}
</div>
{/* 主信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground truncate">{r.name || r.symbol}</span>
<span className="text-[10px] font-mono text-muted shrink-0">{r.symbol}</span>
{r.focus && (
<span className="hidden sm:inline-block px-1.5 py-px rounded bg-purple-400/10 text-purple-300 text-[9px] shrink-0">
{r.focus}
</span>
)}
</div>
{/* 摘要 */}
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-muted/70 flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
{fmtRelative(r.created_at)}
</span>
{r.summary && (
<span className="text-[10px] text-muted/50 truncate">{r.summary}</span>
)}
</div>
</div>
{/* 删除按钮 */}
<button
onClick={e => { e.stopPropagation(); deleteReport(r.id) }}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-danger/10 text-muted hover:text-danger transition-all shrink-0"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
</div>
)
}
// ===== 小工具 =====
function fmtRelative(iso: string): string {
try {
const t = new Date(iso).getTime()
const diff = Date.now() - t
if (diff < 60_000) return '刚刚'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
return new Date(iso).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
} catch { return '' }
}
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { CalendarDays, TrendingUp, FileText, Wallet, Activity, Sparkles } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { CalendarDays, TrendingUp, FileText, Wallet, Activity, Sparkles, AlertTriangle, Loader2 } from 'lucide-react'
import {
useFinancialMetrics,
useFinancialIncome,
@@ -8,6 +9,8 @@ import {
} from '@/lib/useFinancials'
import { fmtPrice, fmtBigNum, fmtDate } from '@/lib/format'
import { Skeleton } from '@/components/data/Skeleton'
import { startAnalysis, findLatestHistoryReport, openHistoryReport } from '@/lib/aiReportStore'
import { toast } from '@/components/Toast'
interface Props {
symbol: string
@@ -112,11 +115,33 @@ function formatValue(v: number | null | undefined, fmt: FmtType): string {
export function StockFinancialDetail({ symbol, name }: Props) {
const [tab, setTab] = useState<TabKey>('metrics')
// AI 财务分析占位: 功能开发中, 点击提示
const [showDevToast, setShowDevToast] = useState(false)
const handleAiAnalysis = () => {
setShowDevToast(true)
setTimeout(() => setShowDevToast(false), 2500)
// AI 分析:点击时检查历史,若已有同标的报告则二次确认
const [checking, setChecking] = useState(false)
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
const handleAiClick = async () => {
if (checking) return
setChecking(true)
try {
const latest = await findLatestHistoryReport(symbol)
if (latest) {
// 有历史报告 → 弹二次确认
setConfirmReport({ id: latest.id, created_at: latest.created_at, focus: latest.focus })
} else {
// 无历史 → 直接分析
await doAnalysis()
}
} catch {
// 查询失败不阻塞,直接分析
await doAnalysis()
} finally {
setChecking(false)
}
}
const doAnalysis = async () => {
const r = await startAnalysis(symbol, name)
if (r.error) toast(r.error, 'error')
}
const metrics = useFinancialMetrics(symbol)
@@ -143,7 +168,7 @@ export function StockFinancialDetail({ symbol, name }: Props) {
const latestAnnounce = rows[0]?.announce_date ?? metrics.data?.data?.[0]?.announce_date ?? null
return (
<div className="relative rounded-card border border-border bg-surface overflow-hidden">
<div className="rounded-card border border-border bg-surface overflow-hidden">
{/* 头部:标的 + 报告期 */}
<div className="px-5 py-4 border-b border-border flex items-center gap-3 flex-wrap">
<div className="flex items-baseline gap-2 min-w-0">
@@ -151,6 +176,15 @@ export function StockFinancialDetail({ symbol, name }: Props) {
<span className="text-xs font-mono text-muted">{symbol}</span>
</div>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={handleAiClick}
disabled={checking}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-btn text-[11px] font-medium border border-purple-400/30 bg-purple-400/10 text-purple-300 hover:bg-purple-400/20 hover:border-purple-400/40 transition-all shrink-0 disabled:opacity-50"
title="AI 财务分析"
>
{checking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
AI
</button>
{latestPeriod && (
<div className="flex items-center gap-1.5 text-xs text-secondary">
<CalendarDays className="h-3.5 w-3.5" />
@@ -160,14 +194,6 @@ export function StockFinancialDetail({ symbol, name }: Props) {
)}
</div>
)}
<button
onClick={handleAiAnalysis}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-btn text-[11px] font-medium border border-purple-400/30 bg-purple-400/10 text-purple-300 hover:bg-purple-400/20 transition-colors shrink-0"
title="AI 财务分析(开发中)"
>
<Sparkles className="h-3 w-3" />
AI
</button>
</div>
</div>
@@ -241,12 +267,78 @@ export function StockFinancialDetail({ symbol, name }: Props) {
)}
</div>
{/* AI 分析开发中提示 */}
{showDevToast && (
<div className="absolute top-16 right-6 z-50 rounded-btn border border-purple-400/40 bg-purple-400/15 px-3 py-2 text-xs text-purple-200 shadow-lg backdrop-blur-sm animate-pulse">
AI
</div>
)}
{/* AI 分析二次确认:已有该标的历史报告 */}
<AnimatePresence>
{confirmReport && (
<div className="fixed inset-0 z-[70] flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setConfirmReport(null)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[90vw] max-w-[400px] rounded-card border border-border bg-base shadow-2xl p-6"
>
<div className="flex items-start gap-3">
<div className="shrink-0 h-10 w-10 rounded-full bg-purple-400/12 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-purple-300" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground mb-1.5"></h3>
<p className="text-xs text-secondary leading-relaxed">
<span className="font-medium text-foreground">{name}</span>
<span className="font-mono text-muted"> {symbol}</span>
<span className="text-purple-300 font-medium"> {fmtReportTime(confirmReport.created_at)} </span>
AI
</p>
<p className="mt-2 text-[11px] text-muted">
,
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
onClick={() => setConfirmReport(null)}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-xs transition-colors"
>
</button>
<button
onClick={() => { if (confirmReport) openHistoryReport(confirmReport.id); setConfirmReport(null) }}
className="px-3 py-1.5 rounded-btn border border-border text-secondary hover:text-foreground text-xs font-medium transition-colors"
>
</button>
<button
onClick={() => { doAnalysis(); setConfirmReport(null) }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-purple-500/80 to-fuchsia-500/80 text-white text-xs font-medium hover:from-purple-500 hover:to-fuchsia-500 transition-all"
>
<Sparkles className="h-3.5 w-3.5" />
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
)
}
// 历史报告时间友好显示
function fmtReportTime(iso: string): string {
try {
const t = new Date(iso).getTime()
const diff = Date.now() - t
if (diff < 60_000) return '刚刚'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return '' }
}
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save, X, Plus, Search } from 'lucide-react'
import { api, genRuleId, type MonitorRule, type MonitorCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { SignalPicker } from '@/components/screener/SignalPicker'
import { usePreferences } from '@/lib/useSharedQueries'
interface Props {
/** 编辑现有规则;null=新建 */
@@ -42,9 +44,15 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const qc = useQueryClient()
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
const { data: prefs } = usePreferences()
const feishuConfigured = !!(prefs?.feishu_webhook_url)
const [editing] = useState(!!rule)
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
const [draft, setDraft] = useState<MonitorRule>(
rule ? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) } : emptyRule(preset),
rule
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) },
)
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
@@ -92,7 +100,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
...d,
conditions: [...d.conditions, op === 'truth'
? { field: 'signal_volume_surge', op: 'truth' }
: { field: 'rsi_14', op: '<', value: 30 }],
// simple 模式(个股弹窗)默认现价; 完整模式默认 RSI 超卖
: { field: simple ? 'close' : 'rsi_14', op: '<', value: simple ? 0 : 30 }],
}))
const removeCond = (idx: number) =>
setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
@@ -139,6 +148,38 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
</div>
{/* 价位条件 (阈值) — 与信号共存, 可选添加 */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"> ()</span>
<button onClick={() => addCond('threshold')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
</div>
{thresholdConds.length > 0 && (
<div className="space-y-1.5">
{thresholdConds.map((c, i) => {
const realIdx = draft.conditions.indexOf(c)
return (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'}</span>
<select value={c.field} onChange={e => updateCond(realIdx, { field: e.target.value })} className="flex-1 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{thresholdFields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(realIdx, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
{operators.map(op => <option key={op} value={op}>{op}</option>)}
</select>
<input type="number" value={c.value ?? 0} onChange={e => updateCond(realIdx, { value: parseFloat(e.target.value) })} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<button onClick={() => removeCond(realIdx)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
)}
</div>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"> ()</span>
<input value={draft.message} onChange={e => setDraft(d => ({ ...d, message: e.target.value }))} placeholder="给这条监控加个备注" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
@@ -335,21 +376,59 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
</label>
</div>
{/* Webhook 推送 (占位, 后续开发) */}
{/* Webhook 推送 — 飞书可用, QMT/ptrade 待定 */}
<div className="rounded-btn border border-border/40 bg-base/40 p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-[11px] font-medium text-foreground">Webhook </span>
<span className="ml-1.5 rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</div>
<label className="flex items-center gap-1.5 cursor-not-allowed opacity-50">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-foreground">Webhook </span>
<span className="text-[9px] text-muted"></span>
</div>
{/* 渠道列表 */}
<div className="space-y-1.5">
{/* 飞书 (可用) */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!draft.webhook_enabled}
onChange={e => setDraft(d => ({ ...d, webhook_enabled: e.target.checked }))}
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-foreground"></span>
<span className="text-[9px] text-muted"></span>
{draft.webhook_enabled && (
<span className={`ml-auto text-[9px] ${feishuConfigured ? 'text-emerald-500' : 'text-warning'}`}>
{feishuConfigured ? '已配置' : '未配置'}
</span>
)}
</label>
{/* QMT (待定) */}
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[10px] text-muted"></span>
<span className="text-[11px] text-secondary">QMT</span>
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</label>
{/* ptrade (待定) */}
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">ptrade</span>
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</label>
</div>
<p className="text-[10px] leading-relaxed text-muted">
( QMT),
</p>
{/* 飞书勾选但全局未配置 → 提示前往设置 */}
{draft.webhook_enabled && !feishuConfigured && (
<p className="text-[10px] leading-relaxed text-warning/80">
Webhook ,
<Link to="/settings?tab=monitoring" className="text-accent hover:text-accent/80"> </Link>
</p>
)}
{draft.webhook_enabled && feishuConfigured && (
<p className="text-[10px] leading-relaxed text-muted">
,
</p>
)}
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
@@ -0,0 +1,547 @@
import { useEffect, useRef, useMemo, useState } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import type { KlineRow, LevelSeries } from '@/lib/api'
/**
* 个股分析专用日 K 图表。
*
* 与 StockDailyKChart/EChartsCandlestick 刻意不复用:
* - 那套图表面向「行情浏览」,强调全套指标副图(MA/MACD/KDJ/BOLL)、涨停标记等;
* - 本图表面向「分析决策」,核心是【关键价位】(压力/支撑/密集区/枢轴/前高前低),
* 通过开关按钮控制各价位组的显隐,布局更简洁(主图 + 成交量即可)。
*
* 预留接口(类型已定义,渲染逻辑留 hook,后续实现):
* - markers: 日期标记点(新闻/暴雷/利好 → markPoint)
* - ranges: 区间高亮(事件区间 → markArea)
* - onDateClick: 点击日期回调(后续接消息面时间轴)
* - 指标副图: 后续如需 MACD/KDJ,按 SUB_CHARTS 模式扩展
*/
// ===== 配色(与主图一致的红涨绿跌,深色背景) =====
const THEME = {
bull: '#C74040',
bear: '#2D9B65',
text: '#A1A1AA',
grid: 'rgba(255,255,255,0.04)',
volUp: 'rgba(240,68,56,0.5)',
volDown: 'rgba(18,183,106,0.5)',
}
// ===== 价位类型(与后端 levels.py 的 LEVEL_TYPES 对齐) =====
export type LevelType = 'sr' | 'pivot' | 'extreme' | 'boll' | 'keltner_s' | 'keltner_m' | 'keltner_l' | 'atr_stop' | 'gap' | 'fib' | 'round'
export interface PriceLevel {
value: number
label: string
type: LevelType
side: 'resistance' | 'support' | 'neutral'
strength?: 'strong' | 'medium' | 'weak'
/** 档位(仅 pivot 有):0=P, 1=R1/S1, 2=R2/S2, 3=R3/S3 */
rank?: number
}
/** 价位组开关配置:label = 按钮文案,color = markLine 颜色 */
export const LEVEL_GROUPS: { key: LevelType; label: string; color: string }[] = [
{ key: 'sr', label: '压力支撑', color: '#F97316' }, // 橙(成交密集区,价量驱动)
{ key: 'pivot', label: '枢轴点', color: '#8B5CF6' }, // 紫
{ key: 'extreme', label: '前高前低', color: '#EAB308' }, // 黄
{ key: 'boll', label: '布林带', color: '#F97316' }, // 橙(MA20±2σ 曲线)
{ key: 'keltner_s',label: 'Keltner短期', color: '#06B6D4' }, // 青(MA20±2ATR 曲线)
{ key: 'keltner_m',label: 'Keltner中期', color: '#22D3EE' }, // 浅青(MA60±2.5ATR 曲线)
{ key: 'keltner_l',label: 'Keltner长期', color: '#67E8F9' }, // 更浅青(MA120±3ATR 曲线)
{ key: 'atr_stop', label: 'ATR止损', color: '#EF4444' }, // 红(警示)
{ key: 'gap', label: '缺口位', color: '#EC4899' }, // 粉
{ key: 'fib', label: '斐波那契', color: '#F59E0B' }, // 金
{ key: 'round', label: '整数关口', color: '#71717A' }, // 灰(心理位,弱视觉)
]
// 通道曲线元数据(单一数据源):供 buildOption 画线 + 右侧面板取最新值共用。
// alignedKey: alignedSeries 中的 key(由 series.boll/keltner/atr 对齐而来)
// group: 属于哪个价位开关组(开关该组即开关这条曲线)
// endLabel: 右侧端点标签(显示最新值的文字)
const CURVE_DEFS: { alignedKey: string; group: LevelType; endLabel: string; color: string; dashed?: boolean }[] = [
{ alignedKey: 'boll_upper', group: 'boll', endLabel: '布林上轨', color: '#F97316', dashed: true },
{ alignedKey: 'boll_lower', group: 'boll', endLabel: '布林下轨', color: '#F97316', dashed: true },
{ alignedKey: 'boll_mid', group: 'boll', endLabel: '布林中轨', color: '#FB923C', dashed: false },
{ alignedKey: 'keltner_s_upper',group: 'keltner_s', endLabel: 'Keltner短上', color: '#06B6D4', dashed: true },
{ alignedKey: 'keltner_s_lower',group: 'keltner_s', endLabel: 'Keltner短下', color: '#06B6D4', dashed: true },
{ alignedKey: 'keltner_m_upper',group: 'keltner_m', endLabel: 'Keltner中上', color: '#22D3EE', dashed: true },
{ alignedKey: 'keltner_m_lower',group: 'keltner_m', endLabel: 'Keltner中下', color: '#22D3EE', dashed: true },
{ alignedKey: 'keltner_l_upper',group: 'keltner_l', endLabel: 'Keltner长上', color: '#67E8F9', dashed: true },
{ alignedKey: 'keltner_l_lower',group: 'keltner_l', endLabel: 'Keltner长下', color: '#67E8F9', dashed: true },
{ alignedKey: 'atr_stop', group: 'atr_stop', endLabel: 'ATR止损', color: '#EF4444', dashed: true },
{ alignedKey: 'atr_tp', group: 'atr_stop', endLabel: 'ATR止盈', color: '#F87171', dashed: true },
]
// ===== 预留:标记 / 区间(后续新闻面、事件区间用) =====
export interface ChartMarker {
date: string
label?: string
color?: string
above?: boolean
}
export interface ChartRange {
start: string
end: string
label?: string
color?: string
}
interface Props {
rows: KlineRow[]
levels?: Record<LevelType, PriceLevel[]>
/** 带状曲线指标(布林带/Keltner/ATR)的每日序列 —— 画成跟随时间漂移的曲线 */
series?: LevelSeries
/** series 数据对应的日期数组(与 series 各数组对齐) */
seriesDates?: string[]
/** 默认开启的价位组 */
defaultLevelTypes?: LevelType[]
/** 预留:新闻/暴雷/利好日期标记 */
markers?: ChartMarker[]
/** 预留:事件区间高亮 */
ranges?: ChartRange[]
/** 预留:点击某根 K 线 */
onDateClick?: (date: string) => void
height?: number
className?: string
}
const VOL_PANE_H = 90
export function AnalysisKChart({
rows,
levels,
series,
seriesDates,
defaultLevelTypes = ['sr', 'pivot', 'keltner_s'],
markers,
ranges,
onDateClick,
height = 460,
className,
}: Props) {
const chartRef = useRef<HTMLDivElement>(null)
const chartInstRef = useRef<ECharts | null>(null)
const [activeTypes, setActiveTypes] = useState<Set<LevelType>>(new Set(defaultLevelTypes))
/** 枢轴点显示到第几档:1=只P+R1/S1, 2=到R2/S2, 3=全档(R3/S3) */
const [pivotRank, setPivotRank] = useState<1 | 2 | 3>(1)
// 数据预处理 + 带状曲线序列对齐(后端 series 的日期范围可能与 rows 不同,需映射)
const { dates, candle, vols, dateIndex, zoomStart, alignedSeries } = useMemo(() => {
const dates = rows.map(r => (typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date)))
const candle = rows.map(r => [r.open, r.close, r.low, r.high])
const vols = rows.map(r => ({
value: r.volume ?? 0,
itemStyle: { color: r.close >= r.open ? THEME.volUp : THEME.volDown },
}))
const dateIndex = new Map(dates.map((d, i) => [d, i]))
// 默认显示最近 6 个月 ≈ 120 个交易日;数据不足则全部显示
const showBars = 120
const zoomStart = dates.length > showBars ? Math.round((1 - showBars / dates.length) * 100) : 0
// 把后端 series(按 seriesDates 对齐)映射到前端 rows 的 dates 顺序
const alignedSeries: Record<string, (number | null)[]> = {}
if (series && seriesDates && seriesDates.length > 0) {
// 构建 seriesDates 索引
const sIdx = new Map(seriesDates.map((d, i) => [d, i]))
// 通用对齐:给定 series 里某条数组,返回与 rows dates 对齐的版本
const align = (arr: (number | null)[] | undefined): (number | null)[] => {
if (!arr) return dates.map(() => null)
return dates.map(d => {
const i = sIdx.get(d)
return i != null ? arr[i] : null
})
}
if (series.boll) {
alignedSeries['boll_upper'] = align(series.boll.upper)
alignedSeries['boll_lower'] = align(series.boll.lower)
if (series.boll.mid) alignedSeries['boll_mid'] = align(series.boll.mid)
}
if (series.keltner_s) {
alignedSeries['keltner_s_upper'] = align(series.keltner_s.upper)
alignedSeries['keltner_s_lower'] = align(series.keltner_s.lower)
}
if (series.keltner_m) {
alignedSeries['keltner_m_upper'] = align(series.keltner_m.upper)
alignedSeries['keltner_m_lower'] = align(series.keltner_m.lower)
}
if (series.keltner_l) {
alignedSeries['keltner_l_upper'] = align(series.keltner_l.upper)
alignedSeries['keltner_l_lower'] = align(series.keltner_l.lower)
}
if (series.atr) {
alignedSeries['atr_stop'] = align(series.atr.stop_loss)
alignedSeries['atr_tp'] = align(series.atr.take_profit)
}
}
return { dates, candle, vols, dateIndex, zoomStart, alignedSeries }
}, [rows, series, seriesDates])
// 构建 option
const buildOption = (): EChartsOption => {
const priceLines = collectPriceLines(levels, activeTypes, pivotRank)
// 三段布局:主图 / 成交量 / 缩放条,从上到下累加,各段之间留间距,互不遮挡
// [16 顶部] [mainH 主图] [8 间距] [volH 成交量] [12 间距] [SLIDER_H 缩放条] [8 底部]
const SLIDER_H = 22
const PAD_TOP = 16
const GAP_MAIN_VOL = 8 // 主图 ↔ 成交量
const GAP_VOL_SLIDER = 12 // 成交量 ↔ 缩放条(留足,避免遮挡)
const PAD_BOTTOM = 8
const volH = VOL_PANE_H
const mainH = height - PAD_TOP - GAP_MAIN_VOL - volH - GAP_VOL_SLIDER - SLIDER_H - PAD_BOTTOM
const volTop = PAD_TOP + mainH + GAP_MAIN_VOL
const sliderBottom = PAD_BOTTOM
// 预留:markPoint(新闻标记)
const markPointData: any[] = (markers ?? [])
.filter(m => dateIndex.has(m.date))
.map(m => ({
coord: [m.date, rows[dateIndex.get(m.date)!].high],
symbol: 'pin', symbolSize: 32,
itemStyle: { color: m.color ?? '#EAB308' },
label: { show: !!m.label, formatter: m.label ?? '', fontSize: 9, color: '#fff' },
}))
// 预留:markArea(事件区间)
const markAreaData: any[] = (ranges ?? [])
.filter(r => dateIndex.has(r.start) && dateIndex.has(r.end))
.map(r => [{
xAxis: r.start, name: r.label ?? '',
itemStyle: { color: r.color ?? 'rgba(234,179,8,0.08)' },
label: r.label ? { show: true, position: 'insideTop', distance: 6, color: '#EAB308', fontSize: 10 } : undefined,
}, { xAxis: r.end }])
const series: any[] = [
{
name: 'K', type: 'candlestick', data: candle, animation: false,
itemStyle: {
color: THEME.bull, color0: THEME.bear,
borderColor: THEME.bull, borderColor0: THEME.bear,
},
markPoint: markPointData.length ? { data: markPointData, animation: false } : undefined,
markArea: markAreaData.length ? { silent: true, data: markAreaData } : undefined,
},
{
name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1,
data: vols, animation: false,
},
]
// 价位水平线 —— 用 line series(恒定值)画水平线,endLabel 显示标签文字;
// 与通道曲线一致,标签落在右侧 grid.right 预留带(外侧),不压蜡烛。
for (const p of priceLines) {
series.push({
name: p.label, type: 'line', silent: true, animation: false,
symbol: 'none',
data: dates.map(() => p.value),
lineStyle: { width: 1, color: p.color, type: 'dashed', opacity: 0.7 },
itemStyle: { color: p.color },
endLabel: {
show: true,
formatter: () => `${p.label} ${p.value.toFixed(2)}`,
color: p.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
distance: 6,
},
})
}
// 带状曲线指标(布林带 / Keltner通道 / ATR止损) —— 跟随行情漂移的曲线
// 单一数据源 CURVE_DEFS 驱动:每条曲线带 endLabel(右侧端点标签),显示最新数值
for (const def of CURVE_DEFS) {
if (!activeTypes.has(def.group)) continue
const data = alignedSeries[def.alignedKey]
if (!data || !data.some(v => v != null)) continue
// 取最后一个有效值作为右侧端点显示文字
let lastVal: number | null = null
for (let i = data.length - 1; i >= 0; i--) {
if (data[i] != null) { lastVal = data[i]; break }
}
series.push({
name: def.endLabel, type: 'line', data: data.map(v => v ?? '-'),
smooth: true, symbol: 'none', silent: true, animation: false,
lineStyle: { width: 1, color: def.color, type: def.dashed === false ? 'solid' : 'dashed', opacity: 0.8 },
itemStyle: { color: def.color },
// 右侧端点标签:显示该通道的最新数值,距绘图区右缘留 6px 间距
endLabel: lastVal != null ? {
show: true,
formatter: () => `${lastVal!.toFixed(2)}`,
color: def.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
distance: 6,
} : undefined,
})
}
return {
animation: false,
backgroundColor: 'transparent',
// grid.right 留出足够宽度给价位标签文字区:蜡烛只占左侧主区域,
// 价位线右端的标签文字显示在这条预留带里,不压在蜡烛上。
// 预留 ~144px:最长标签(如「成交密集区(POC) 12.34」)约 13 字符,fontSize 9 等宽。
grid: [
{ left: 56, right: 144, top: 16, height: mainH },
{ left: 56, right: 144, top: volTop, height: volH },
],
xAxis: [
{
type: 'category', data: dates, boundaryGap: true,
axisLine: { lineStyle: { color: THEME.grid } },
axisLabel: { color: THEME.text, fontSize: 10 },
splitLine: { show: false },
axisPointer: { show: true, label: { show: false } },
},
{
type: 'category', gridIndex: 1, data: dates, boundaryGap: true,
axisLabel: { show: false }, axisLine: { show: false }, axisTick: { show: false },
},
],
yAxis: [
{ scale: true, splitLine: { lineStyle: { color: THEME.grid } },
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' } },
{ scale: true, gridIndex: 1, splitNumber: 2,
// 成交量区不画背景横线
splitLine: { show: false },
axisLabel: { color: THEME.text, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => fmtVol(v) } },
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: zoomStart, end: 100 },
{ type: 'slider', xAxisIndex: [0, 1], bottom: sliderBottom, height: SLIDER_H, start: zoomStart, end: 100,
borderColor: 'transparent', fillerColor: 'rgba(255,255,255,0.06)',
handleStyle: { color: '#52525B' }, textStyle: { color: THEME.text, fontSize: 10 } },
],
// 不弹 hover tooltip(用户要求);但保留十字线 axisPointer 作为缩放/定位参照
tooltip: { show: false },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
series,
}
}
// 初始化 + 数据更新
useEffect(() => {
if (!chartRef.current) return
if (!chartInstRef.current) {
chartInstRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
chartInstRef.current.on('click', (params: any) => {
// 预留:点击 K 线(非 markPoint/markLine)回调
if (params.componentType === 'series' && params.seriesType === 'candlestick' && onDateClick) {
onDateClick(dates[params.dataIndex])
}
})
}
chartInstRef.current.setOption(buildOption(), true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, levels, series, seriesDates, activeTypes, pivotRank, markers, ranges, height])
// resize
useEffect(() => {
const inst = chartInstRef.current
if (!inst) return
const onResize = () => inst.resize()
window.addEventListener('resize', onResize)
return () => { window.removeEventListener('resize', onResize); inst.dispose(); chartInstRef.current = null }
}, [])
const toggleType = (t: LevelType) => {
setActiveTypes(prev => {
const next = new Set(prev)
if (next.has(t)) next.delete(t)
else next.add(t)
return next
})
}
return (
<div className={className}>
{/* 价位开关按钮组 */}
{levels && (
<div className="flex flex-wrap items-center gap-1.5 mb-2">
<span className="text-[10px] text-muted mr-1"></span>
{LEVEL_GROUPS.map(g => {
const active = activeTypes.has(g.key)
// 枢轴点数量按当前档位过滤显示;其他组显示原始数量
const raw = levels[g.key] ?? []
const count = g.key === 'pivot'
? raw.filter(p => p.rank === undefined || p.rank <= pivotRank).length
: raw.length
return (
<button
key={g.key}
onClick={() => toggleType(g.key)}
disabled={raw.length === 0}
title={`${g.label} (${count} 个)`}
className={`inline-flex items-center gap-1 h-6 px-2 rounded-md text-[10px] font-medium border transition-all disabled:opacity-30 disabled:cursor-not-allowed ${
active
? 'text-foreground'
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
}`}
style={active ? { borderColor: g.color + '66', backgroundColor: g.color + '1a' } : undefined}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: active ? g.color : '#52525B' }} />
{g.label}
<span className="opacity-50">{count}</span>
</button>
)
})}
{/* 枢轴点档位选择器 —— 仅当枢轴点开启时显示 */}
{activeTypes.has('pivot') && (levels.pivot?.length ?? 0) > 0 && (
<div className="inline-flex items-center gap-0.5 ml-1 pl-2 border-l border-border/40">
<span className="text-[10px] text-muted mr-1"></span>
{([1, 2, 3] as const).map(r => (
<button
key={r}
onClick={() => setPivotRank(r)}
title={r === 1 ? 'P + R1/S1(3 个)' : r === 2 ? '到 R2/S2(5 个)' : '全档 R3/S3(7 个)'}
className={`h-6 px-2 rounded-md text-[10px] font-mono border transition-all ${
pivotRank === r
? 'bg-[#8B5CF6]/15 border-[#8B5CF6]/40 text-[#c4b5fd]'
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
}`}
>
{r}
</button>
))}
</div>
)}
</div>
)}
{/* 图表:右侧预留带(grid.right 预留)显示价位标签文字,不压蜡烛 */}
<div ref={chartRef} style={{ width: '100%', height }} />
{/* 价位统计面板:把当前开启的点位按"压力 / 支撑"结构化列出 */}
{levels && (
<LevelOverview
levels={levels}
activeTypes={activeTypes}
pivotRank={pivotRank}
close={rows.length ? rows[rows.length - 1].close : undefined}
/>
)}
</div>
)
}
// ===== 价位统计面板(图表下方,结构化文本展示) =====
function LevelOverview({
levels, activeTypes, pivotRank, close,
}: {
levels: Record<LevelType, PriceLevel[]>
activeTypes: Set<LevelType>
pivotRank: 1 | 2 | 3
close?: number
}) {
// 收集当前显示的点位(同 collectPriceLines 的过滤逻辑)
const visible: PriceLevel[] = []
for (const g of LEVEL_GROUPS) {
if (!activeTypes.has(g.key)) continue
for (const p of levels[g.key] ?? []) {
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
visible.push(p)
}
}
if (visible.length === 0) return null
// 按方向分两组:压力位(在当前价之上) / 支撑位(之下),各自按距当前价远近排序
const cur = close ?? visible[0].value
const resistances = visible
.filter(p => p.side === 'resistance')
.sort((a, b) => a.value - b.value) // 由近及远(低→高)
const supports = visible
.filter(p => p.side === 'support')
.sort((a, b) => b.value - a.value) // 由近及远(高→低)
const neutrals = visible.filter(p => p.side === 'neutral')
const fmtPct = (v: number) => {
if (!cur) return ''
const pct = ((v - cur) / cur) * 100
const sign = pct >= 0 ? '+' : ''
return `${sign}${pct.toFixed(1)}%`
}
const Row = ({ p }: { p: PriceLevel }) => {
const color = LEVEL_GROUPS.find(g => g.key === p.type)?.color ?? THEME.text
return (
<div className="flex items-center gap-2 py-0.5">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-[11px] text-secondary w-24 shrink-0 truncate">{p.label}</span>
<span className="text-[11px] font-mono text-foreground">{p.value.toFixed(2)}</span>
<span className="text-[9px] font-mono text-muted">{fmtPct(p.value)}</span>
</div>
)
}
return (
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 rounded-lg border border-border/40 bg-base/20 px-3 py-2">
{/* 当前价 */}
<div className="sm:col-span-2 flex items-center gap-2 pb-1 border-b border-border/30 mb-0.5">
<span className="text-[10px] text-muted"></span>
<span className="text-xs font-mono font-medium text-foreground">{cur.toFixed(2)}</span>
</div>
{/* 压力位(从近到远,即从低到高)倒序展示:最高的在最上 */}
{resistances.length > 0 && (
<div>
<div className="text-[10px] font-medium text-bear mb-0.5"> </div>
{[...resistances].reverse().map((p, i) => <Row key={`r-${i}`} p={p} />)}
</div>
)}
{/* 支撑位 + 中性(枢轴位 P) */}
<div>
{supports.length > 0 && (
<>
<div className="text-[10px] font-medium text-bull mb-0.5"> </div>
{supports.map((p, i) => <Row key={`s-${i}`} p={p} />)}
</>
)}
{neutrals.length > 0 && (
<div className={supports.length > 0 ? 'mt-2' : ''}>
{supports.length === 0 && <div className="text-[10px] font-medium text-muted mb-0.5"></div>}
{neutrals.map((p, i) => <Row key={`n-${i}`} p={p} />)}
</div>
)}
</div>
</div>
)
}
// ===== 工具:收集要画的水平价位线(按开启的组 + 档位 + 强度配色) =====
// 注意:带状指标(布林带/Keltner/ATR)改用曲线渲染,不在此画水平线,避免重复。
function collectPriceLines(
levels: Record<LevelType, PriceLevel[]> | undefined,
active: Set<LevelType>,
pivotRank: 1 | 2 | 3,
): { value: number; label: string; color: string }[] {
if (!levels) return []
const out: { value: number; label: string; color: string }[] = []
for (const g of LEVEL_GROUPS) {
if (!active.has(g.key)) continue
for (const p of levels[g.key] ?? []) {
// 枢轴点:按档位过滤(rank>P 的,只显示到选定的档位)
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
// 波动通道类(boll / keltner三档 / atr_stop)整组走曲线渲染,不画水平线;
// sr 组现为成交密集区水平点,直接画线即可,无需特判。
if (p.type === 'boll' || p.type === 'keltner_s' || p.type === 'keltner_m'
|| p.type === 'keltner_l' || p.type === 'atr_stop') continue
out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color) })
}
}
return out
}
function strengthColor(strength: string | undefined, base: string): string {
// strong 用实色,medium 用 0.85,weak 用 0.55 透明
if (strength === 'weak') return base + '8C'
if (strength === 'medium') return base + 'D9'
return base
}
function fmtVol(v: number): string {
if (!v) return '0'
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
if (v >= 1e4) return (v / 1e4).toFixed(0) + '万'
return v.toFixed(0)
}
@@ -0,0 +1,207 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Loader2, Check, AlertCircle } from 'lucide-react'
import { useBubbleTasks, restoreDialog } from '@/lib/stockAnalysisStore'
import type { ActiveTask } from '@/lib/stockAnalysisStore'
/**
* AI 个股分析任务全局气泡 —— 与财务分析胶囊并列,蓝色主题区分。
* 挂在右侧,拖拽丝滑(逻辑同 AiReportBubble,独立状态池)。
*
* 与财务胶囊的差异:蓝色系 + "个股分析中"文案,避免与财务分析混淆。
*/
const BUBBLE_W = 148
const EDGE_MARGIN = 12
export function StockAnalysisBubble() {
const activeTasks = useBubbleTasks()
const containerRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos())
const draggingRef = useRef(false)
const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 })
const movedRef = useRef(false)
const clickTargetRef = useRef<(() => void) | null>(null)
const applyTransform = useCallback((x: number, y: number) => {
const el = containerRef.current
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)`
}, [])
const clamp = useCallback((x: number, y: number) => {
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
const maxY = window.innerHeight - 80
return {
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
}
}, [])
const onPointerDown = useCallback((e: React.PointerEvent) => {
draggingRef.current = true
movedRef.current = false
dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y }
const el = containerRef.current
if (el) el.classList.add('dragging')
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}, [pos.x, pos.y])
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!draggingRef.current) return
const dx = e.clientX - dragData.current.mx
const dy = e.clientY - dragData.current.my
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
const c = clamp(dragData.current.ox + dx, dragData.current.oy + dy)
applyTransform(c.x, c.y)
}, [clamp, applyTransform])
const onPointerUp = useCallback(() => {
if (!draggingRef.current) return
draggingRef.current = false
const el = containerRef.current
if (el) el.classList.remove('dragging')
if (movedRef.current) {
setPos(prev => {
const transform = el?.style.transform ?? ''
const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/)
const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev
savePos(finalPos)
return finalPos
})
} else {
const fn = clickTargetRef.current
clickTargetRef.current = null
fn?.()
}
}, [])
useEffect(() => {
const onResize = () => {
setPos(prev => {
const c = clamp(prev.x, prev.y)
if (c.x !== prev.x || c.y !== prev.y) {
applyTransform(c.x, c.y)
return c
}
return prev
})
}
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [clamp, applyTransform])
useEffect(() => { applyTransform(pos.x, pos.y) }, [pos.x, pos.y, applyTransform])
if (activeTasks.length === 0) return null
// 默认位置偏下,避免与财务胶囊(右下)重叠
return (
<div
ref={containerRef}
className="sa-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
style={{
width: `${BUBBLE_W}px`,
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
touchAction: 'none',
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<AnimatePresence mode="popLayout">
{activeTasks.map((task, i) => (
<BubbleItem
key={task.id}
task={task}
isLast={i === activeTasks.length - 1}
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
/>
))}
</AnimatePresence>
<style>{`.sa-bubble-root.dragging { transition: none !important; }`}</style>
</div>
)
}
function BubbleItem({ task, isLast, onPointerDown }: {
task: ActiveTask
isLast: boolean
onPointerDown: () => void
}) {
const isWorking = task.phase === 'loading' || task.phase === 'streaming'
const isError = task.phase === 'error'
// 蓝色系(区别于财务分析的紫色)
const accent = isWorking
? 'from-sky-500/25 to-blue-500/20 text-sky-300 border-sky-300/40 shadow-[0_6px_24px_-10px_rgba(14,165,233,0.5)]'
: isError
? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]'
: 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]'
return (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -8 }}
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
className={isLast ? '' : 'mb-1.5'}
>
<div
onPointerDown={onPointerDown}
role="button"
tabIndex={0}
title={isWorking ? '个股分析中,点击恢复' : isError ? '分析失败,点击重试' : '点击查看个股分析报告'}
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
>
{isWorking && (
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-sky-200 to-transparent animate-sa-bubble-progress" />
</div>
)}
<span className="flex h-4 w-4 items-center justify-center shrink-0">
{isWorking ? <Loader2 className="h-3 w-3 animate-spin" />
: isError ? <AlertCircle className="h-3 w-3" />
: <Check className="h-3 w-3" />}
</span>
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
{task.name || task.symbol}
</span>
<span className="shrink-0 text-[9px] leading-none">
{isWorking ? <span className="text-sky-300/80"></span>
: isError ? <span className="text-red-300/80"></span>
: <span className="text-emerald-300/80"></span>}
</span>
</div>
<style>{`
@keyframes sa-bubble-progress { 0% { transform: translateX(-100%); } 100% { transform: translateX(300%); } }
.animate-sa-bubble-progress { animation: sa-bubble-progress 1.6s ease-in-out infinite; }
`}</style>
</motion.div>
)
}
const POS_KEY = 'sa_bubble_pos'
function loadPos(): { x: number; y: number } {
// 默认右下,偏上一点(避开财务胶囊的右下位置)
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 320)
try {
const v = localStorage.getItem(POS_KEY)
if (v) {
const p = JSON.parse(v)
if (typeof p.x === 'number' && typeof p.y === 'number') {
return {
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
}
}
}
} catch { /* ignore */ }
return { x: defaultX, y: defaultY }
}
function savePos(p: { x: number; y: number }) {
try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ }
}
@@ -0,0 +1,256 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Sparkles, Loader2, AlertTriangle, Copy, Check, RefreshCw,
Settings2, Send, Wand2, Minimize2, History, LineChart,
} from 'lucide-react'
import { cn } from '@/lib/cn'
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
import {
type ActiveTask, type HistoryReport,
minimizeDialog, closeDialog, startAnalysis,
} from '@/lib/stockAnalysisStore'
/**
* AI 个股分析对话框 —— 蓝色主题,与财务分析对话框区分。
* 复用 MarkdownRenderer(通用 markdown 渲染);标题/配色/文案独立。
*/
interface Props {
task: ActiveTask | HistoryReport | null
mode: 'active' | 'history' | null
minimized: boolean
}
type Phase = 'loading' | 'streaming' | 'done' | 'error'
function getPhase(task: ActiveTask | HistoryReport | null): Phase {
if (!task) return 'loading'
if ('phase' in task) return task.phase
return 'done'
}
function getContent(task: ActiveTask | HistoryReport | null): string {
return task?.content ?? ''
}
function getMeta(task: ActiveTask | HistoryReport | null) {
if (!task) return null
if ('meta' in task) return task.meta
return { summary: task.summary, close: task.close, levels: task.levels }
}
export function StockAnalysisDialog({ task, mode, minimized }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const [focus, setFocus] = useState('')
const [copied, setCopied] = useState(false)
const phase = getPhase(task)
const content = getContent(task)
const meta = getMeta(task)
const isHistory = mode === 'history'
const isWorking = phase === 'loading' || phase === 'streaming'
const open = !!task && !minimized
useEffect(() => {
if (open && phase === 'streaming' && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [content, phase, open])
useEffect(() => {
setFocus(task && 'focus' in task ? task.focus : '')
}, [task])
const handleStartNew = useCallback(async () => {
if (!task) return
const name = 'name' in task ? task.name : ''
await startAnalysis(task.symbol, name, focus.trim())
}, [task, focus])
const handleCopy = async () => {
if (!content) return
try {
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { /* ignore */ }
}
if (!open) return null
const error = task && 'error' in task ? task.error : ''
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* 头部 —— 蓝色主题 */}
<div className="relative px-5 py-3.5 border-b border-border/50 bg-gradient-to-r from-sky-500/[0.06] via-blue-500/[0.04] to-transparent">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500/20 to-blue-500/15 border border-sky-400/30 shrink-0">
{isHistory
? <History className="h-4.5 w-4.5 text-sky-300" />
: <LineChart className="h-4.5 w-4.5 text-sky-300" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground truncate">
{isHistory ? '历史分析报告' : 'AI 个股分析'}
</span>
{task && <span className="text-xs text-secondary truncate">{task.name}</span>}
{task && <span className="text-[10px] font-mono text-muted shrink-0">{task.symbol}</span>}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted">
{meta?.summary ? (
<span className="flex items-center gap-1 truncate">
<Sparkles className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{meta.summary}</span>
</span>
) : isWorking ? <span></span> : null}
{phase === 'streaming' && (
<span className="flex items-center gap-1 text-sky-300 shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-sky-400 animate-pulse" />
</span>
)}
{isHistory && task && 'created_at' in task && (
<span className="shrink-0">{fmtRelative(task.created_at)}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{content && !isWorking && (
<button onClick={handleCopy} title="复制全文"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
)}
{!isHistory && isWorking && (
<button onClick={minimizeDialog} title="最小化为气泡,后台继续生成"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<Minimize2 className="h-4 w-4" />
</button>
)}
{(!isWorking || isHistory) && (
<button onClick={closeDialog} title="关闭"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
{/* 内容区 */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 min-h-[280px]">
{phase === 'loading' && !content && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="relative">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-sky-500/20 to-blue-500/15 border border-sky-400/30 flex items-center justify-center">
<LineChart className="h-4.5 w-4.5 text-sky-300 animate-pulse" />
</div>
<Loader2 className="absolute -inset-1 h-12 w-12 text-sky-400/40 animate-spin" style={{ animationDuration: '3s' }} />
</div>
<div className="text-xs text-secondary">AI </div>
<div className="text-[10px] text-muted"> K / / / ,</div>
</div>
)}
{phase === 'error' && (
<div className="flex flex-col items-center justify-center py-14 gap-3">
<div className="h-11 w-11 rounded-full bg-danger/10 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="text-sm font-medium text-foreground"></div>
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
{error.includes('AI') && (
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
<Settings2 className="h-3.5 w-3.5" /> AI
</button>
)}
<button onClick={handleStartNew}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-sky-500/15 border border-sky-400/30 text-xs text-sky-300 hover:bg-sky-500/20 transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
{(content || phase === 'streaming') && (
<div className="relative">
<MarkdownRenderer content={content} />
{phase === 'streaming' && (
<span className="inline-block w-1.5 h-3.5 bg-sky-400 ml-0.5 align-middle animate-pulse rounded-sm" />
)}
</div>
)}
</div>
{/* 底部:关注点输入 */}
<div className="border-t border-border/50 bg-surface/60 px-5 py-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted shrink-0">
<Wand2 className="h-3 w-3" />
<span className="hidden sm:inline"></span>
</div>
<input
type="text"
value={focus}
onChange={e => setFocus(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (phase === 'done' || phase === 'error' || isHistory)) handleStartNew() }}
disabled={isWorking}
placeholder={isHistory ? '修改关注重点,回车重新生成' : (phase === 'done' ? '如:重点看能否突破压力位…回车重新分析' : '可留空,留空则全面分析')}
className={cn(
'flex-1 h-8 px-3 rounded-lg bg-base ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/40',
'focus:outline-none focus:ring-2 focus:ring-sky-400/30 transition-shadow disabled:opacity-50',
)}
/>
{isHistory ? (
<button
onClick={handleStartNew}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs font-medium text-sky-300 hover:from-sky-500/30 hover:to-blue-500/20 transition-all shrink-0"
title="以此关注点重新生成新报告"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleStartNew}
disabled={isWorking}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs font-medium text-sky-300 hover:from-sky-500/30 hover:to-blue-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0"
title={focus.trim() ? '按关注重点重新分析' : '重新分析'}
>
{isWorking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : phase === 'done' ? <RefreshCw className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
{phase === 'done' ? '重新分析' : '分析'}
</button>
)}
</div>
<p className="mt-1.5 text-[10px] text-muted/50 leading-relaxed">
{isHistory
? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
: '报告由项目已配置的 AI 模型基于本地行情与财务数据生成;消息面维度暂依据价量异动推断。报告仅供参考,不构成投资建议。'}
</p>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
function fmtRelative(iso: string): string {
try {
const t = new Date(iso).getTime()
const diff = Date.now() - t
if (diff < 60_000) return '刚刚'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
return new Date(iso).toLocaleDateString('zh-CN')
} catch { return '' }
}
@@ -0,0 +1,12 @@
import { useDialogTask, useDialogState } from '@/lib/stockAnalysisStore'
import { StockAnalysisDialog } from './StockAnalysisDialog'
/**
* AI 个股分析对话框宿主 —— 单点挂载在 Layout。
* 与财务分析的 AiAnalysisHost 并列,独立 store,蓝色主题。
*/
export function StockAnalysisHost() {
const { task, mode } = useDialogTask()
const { minimized } = useDialogState()
return <StockAnalysisDialog task={task} mode={mode} minimized={minimized} />
}
+336
View File
@@ -0,0 +1,336 @@
import { useSyncExternalStore } from 'react'
import { api } from './api'
/**
* AI 财务分析 —— 全局任务/报告 store(与 UI 解耦)。
*
* 设计要点:
* 1. 流式接收逻辑在这里,与弹窗组件解耦 → 用户关闭/最小化弹窗,后台流照常累积。
* 2. useSyncExternalStore 订阅 → 任意组件(弹窗、气泡、历史面板)实时同步。
* 3. "活跃任务"上限 MAX_ACTIVE=3:同时进行的任务最多 3 个,超出拒绝新建。
* (历史报告名额 MAX_REPORTS=20 在后端裁剪,与活跃任务名额分离)
* 4. 同 symbol 已有活跃任务 → 直接聚焦那个,不新建第 2 个。
* 5. 任务完成(收到 done 或 content 非空且流结束)→ 自动存后端 + 移入历史 + 弹窗可恢复为"历史模式"。
*/
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
export interface ActiveTask {
id: string // 任务 id(前端生成,与最终 report id 解耦)
symbol: string
name: string
focus: string
phase: Phase
content: string // 累积的 Markdown
error: string
meta: { summary?: string; periods?: number } | null
createdAt: number // ms 时间戳
savedReportId?: string // 完成后存到后端的报告 id
doneAt?: number // 进入 done/error 态的时间戳(用于气泡过期清理)
dismissed?: boolean // 用户已从气泡点击查看过 → 不再在气泡显示
}
export interface HistoryReport {
id: string
symbol: string
name: string
focus: string
content: string
periods?: number
summary?: string
created_at: string
}
const MAX_ACTIVE = 3
// ===== 全局状态 =====
let activeTasks: ActiveTask[] = []
let history: HistoryReport[] = []
let historyLoaded = false
const listeners = new Set<() => void>()
// 当前"前台"展示的任务:
// - 活跃任务 id(正在生成/刚完成,对话框打开)
// - 或 'history:<id>'(查看历史报告)
// - 或 null(对话框关闭/最小化)
let activeDialogTaskId: string | null = null
let dialogMinimized = false // 对话框是否最小化为气泡
function emit() { listeners.forEach(fn => fn()) }
function subscribe(fn: () => void) {
listeners.add(fn)
return () => { listeners.delete(fn) }
}
function normalizeAiError(msg: string) {
return msg.includes('API Key') || msg.includes('api_key')
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
: msg
}
// 快照必须返回稳定引用:只有内容真正变化时才返回新数组/对象。
// useSyncExternalStore 用 Object.is 比较,getSnapshot 必须缓存。
let _activeSnap: ActiveTask[] = []
let _historySnap: HistoryReport[] = []
interface DialogSnap { taskId: string | null; minimized: boolean }
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
function rebuildSnap() {
_activeSnap = activeTasks
_historySnap = history
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
}
function getActiveSnapshot() { return _activeSnap }
function getHistorySnapshot() { return _historySnap }
function getDialogSnapshot() { return _dialogSnap }
function patchTask(id: string, patch: Partial<ActiveTask>) {
activeTasks = activeTasks.map(t => {
if (t.id !== id) return t
const next = { ...t, ...patch }
// 首次进入 done/error 态时记录 doneAt
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
next.doneAt = Date.now()
}
return next
})
rebuildSnap()
emit()
}
// ===== 公开:查询 hooks =====
export function useBubbleTasks(): ActiveTask[] {
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
// 同时订阅对话框状态:最小化/打开/关闭会改变气泡可见性,需独立触发重渲染。
// (否则最小化时 activeTasks 引用未变,useSyncExternalStore 不会重渲染,胶囊不出现)
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
const ds = _dialogSnap
return all.filter(t => {
// 进行中:始终显示(dismissed 仅作用于完成态,不影响生成中的任务再次最小化)
if (t.phase === 'loading' || t.phase === 'streaming') {
// 除非对话框正打开看着它(非最小化)
return !(ds.taskId === t.id && !ds.minimized)
}
// 完成/失败态:常驻显示,直到用户主动点击查看(dismissed)。
// 不设自动过期 —— 胶囊是持续可见的状态指示器,历史报告列表是查看入口。
if (t.dismissed) return false // 用户已点击查看过 → 移除
if (!ds.minimized && ds.taskId === t.id) return false // 对话框正展示 → 不重复
return true
})
}
/** 兼容旧调用名(Layout 等处可能引用) */
export const useActiveTasks = useBubbleTasks
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
return { reports, loaded: historyLoaded }
}
export function useDialogState() {
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
}
/** 当前对话框要展示的任务(活跃或历史),null=未打开。 */
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
const ds = useDialogState()
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
if (!ds.taskId) return { task: null, mode: null }
if (ds.taskId.startsWith('history:')) {
const rid = ds.taskId.slice('history:'.length)
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
}
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
}
// ===== 公开:动作 =====
/** 拉取历史报告(惰性,首次需要时调用)。 */
export async function loadHistory(): Promise<void> {
try {
const res = await api.financialReportsList()
history = res.reports ?? []
historyLoaded = true
rebuildSnap()
emit()
} catch {
// 静默失败,列表会显示空
}
}
/**
* 查询某只股票最近一次的历史分析报告(用于二次确认提示)。
* 若历史未加载,先触发拉取。
* @returns 最近一条报告,或 null
*/
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
if (!historyLoaded) await loadHistory()
// history 已按 created_at 降序,取第一条匹配
return history.find(r => r.symbol === symbol) ?? null
}
/**
* 启动一个新的 AI 分析任务。
* @returns 任务 id;若超出上限或已有活跃任务,返回 { error }。
*/
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
// 同 symbol 已有活跃任务 → 直接聚焦它
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
if (existing) {
activeDialogTaskId = existing.id
dialogMinimized = false
rebuildSnap()
emit()
return { id: existing.id }
}
// 上限检查
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
if (ongoing.length >= MAX_ACTIVE) {
return { error: `同时进行的分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
}
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
const task: ActiveTask = {
id, symbol, name, focus,
phase: 'loading', content: '', error: '',
meta: null, createdAt: Date.now(),
}
activeTasks = [...activeTasks, task]
activeDialogTaskId = id
dialogMinimized = false
rebuildSnap()
emit()
// 启动流式接收(后台运行,不阻塞)
runStream(id, symbol, focus)
return { id }
}
async function runStream(id: string, symbol: string, focus: string) {
try {
let firstDelta = true
for await (const chunk of api.financialAnalyzeStream(symbol, focus)) {
// 任务可能已被取消(不在列表里了)→ 终止
const cur = activeTasks.find(t => t.id === id)
if (!cur) return
switch (chunk.type) {
case 'meta':
patchTask(id, { meta: { summary: chunk.summary, periods: chunk.periods } })
break
case 'delta':
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
patchTask(id, { content: cur.content + (chunk.content ?? '') })
break
case 'error':
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
return
case 'done':
// 标记完成,稍后持久化(content 可能还在最后几个 delta 里,以 done 时为准)
patchTask(id, { phase: 'done' })
break
}
}
// 流正常结束 → 持久化报告
const final = activeTasks.find(t => t.id === id)
if (final && final.phase !== 'error' && final.content) {
try {
const res = await api.financialReportSave({
symbol: final.symbol,
name: final.name,
focus: final.focus,
content: final.content,
periods: final.meta?.periods,
summary: final.meta?.summary ?? '',
})
if (res.report) {
patchTask(id, { savedReportId: res.report.id })
// 加到历史列表头部
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
historyLoaded = true
rebuildSnap()
emit()
// 任务完成:不自动弹出对话框,只在胶囊显示"已完成"态,用户想看再点。
// (若对话框正打开看此任务,内容已实时更新;最小化/在别处则胶囊亮起完成态)
}
} catch {
// 持久化失败不影响前端已展示的内容
}
}
} catch (e: any) {
const msg = String(e?.message ?? '分析失败')
patchTask(id, {
phase: 'error',
error: normalizeAiError(msg),
})
}
}
/** 打开对话框(活跃任务或历史报告)。 */
export function openDialog(taskId: string) {
activeDialogTaskId = taskId
dialogMinimized = false
rebuildSnap()
emit()
}
/** 最小化对话框 → 变成气泡。 */
export function minimizeDialog() {
dialogMinimized = true
rebuildSnap()
emit()
}
/** 关闭对话框(活跃任务继续在后台跑,仅移除对话框视图)。
* 对历史报告:仅关闭视图。
*/
export function closeDialog() {
activeDialogTaskId = null
dialogMinimized = false
rebuildSnap()
emit()
}
/** 从气泡恢复对话框。
* 仅对已完成/失败的任务标记 dismissed(看过结果就不必再弹);
* 生成中的任务不标记 —— 用户再次最小化时气泡应重新出现。
*/
export function restoreDialog(taskId: string) {
const t = activeTasks.find(x => x.id === taskId)
if (t && (t.phase === 'done' || t.phase === 'error')) {
patchTask(taskId, { dismissed: true })
}
activeDialogTaskId = taskId
dialogMinimized = false
rebuildSnap()
emit()
}
/** 重试一个失败/已完成的任务(以新任务方式重新分析)。 */
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
return startAnalysis(task.symbol, task.name, task.focus)
}
/** 删除历史报告。 */
export async function deleteReport(reportId: string): Promise<void> {
try {
await api.financialReportDelete(reportId)
history = history.filter(r => r.id !== reportId)
rebuildSnap()
emit()
} catch {
// 静默
}
}
/** 打开历史报告到对话框。 */
export function openHistoryReport(reportId: string) {
activeDialogTaskId = `history:${reportId}`
dialogMinimized = false
rebuildSnap()
emit()
}
+483 -77
View File
@@ -7,68 +7,22 @@ import { toast } from '@/components/Toast'
const BASE = ''
function getAccessToken(): string | null {
try { return localStorage.getItem('access_token') } catch { return null }
}
function clearAccessToken(): void {
try { localStorage.removeItem('access_token') } catch { /* ignore */ }
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const isFormData = init?.body instanceof FormData
const headers: Record<string, string> = {}
if (!isFormData) headers['Content-Type'] = 'application/json'
const token = getAccessToken()
if (token) headers['X-Access-Token'] = token
// 合并调用方传入的 headers(如显式覆盖 Content-Type)
if (init?.headers) {
const incoming = init.headers as Record<string, string>
for (const [k, v] of Object.entries(incoming)) {
if (v != null) headers[k] = v
}
}
const res = await fetch(`${BASE}${path}`, { ...init, headers })
if (!res.ok) {
// 401 时清除令牌并跳转登录页,避免无限弹 toast
if (res.status === 401) {
clearAccessToken()
if (typeof window !== 'undefined' && window.location.pathname !== '/verify') {
window.location.href = '/verify'
}
}
let detail = ''
try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
const msg = detail || `${res.status} ${res.statusText}`
toast(msg, 'error')
// 401 (未登录/会话过期) 不弹 toast — 由全局认证拦截器统一跳登录页, 避免刷屏
if (res.status !== 401) toast(msg, 'error')
throw new Error(msg)
}
return res.json() as Promise<T>
}
// ===== Access UUID Gate =====
export interface VerifyAccessUuidResponse {
valid: boolean
role?: 'admin' | 'user'
token?: string
}
export interface AccessAuthStatusResponse {
enabled: boolean
verified: boolean
role?: 'admin' | 'user'
}
export interface UuidRecord {
uuid: string
label: string
enabled: boolean
created_at: number
}
// ===== Capabilities =====
export interface CapabilityLimits {
rpm: number | null
@@ -151,6 +105,62 @@ export interface FinancialCashFlowRecord {
[key: string]: any
}
/** AI 财务分析历史报告 */
export interface AiFinancialReport {
id: string
symbol: string
name: string
focus: string
content: string
periods?: number
summary?: string
created_at: string
}
// ===== 个股分析 =====
export type LevelType = 'sr' | 'pivot' | 'extreme' | 'boll' | 'keltner_s' | 'keltner_m' | 'keltner_l' | 'atr_stop' | 'gap' | 'fib' | 'round'
export interface PriceLevel {
value: number
label: string
type: LevelType
side: 'resistance' | 'support' | 'neutral'
strength?: 'strong' | 'medium' | 'weak'
/** 档位(仅 pivot 有):0=P, 1=R1/S1, 2=R2/S2, 3=R3/S3。前端按"显示到第几档"过滤。 */
rank?: number
}
/** 带状曲线指标(布林带/Keltner/ATR)的每日时间序列,与 dates 对齐。 */
export interface LevelSeries {
boll?: { upper: (number | null)[]; lower: (number | null)[]; mid?: (number | null)[] }
keltner_s?: { upper: (number | null)[]; lower: (number | null)[] }
keltner_m?: { upper: (number | null)[]; lower: (number | null)[] }
keltner_l?: { upper: (number | null)[]; lower: (number | null)[] }
atr?: { stop_loss: (number | null)[]; take_profit: (number | null)[] }
}
export interface StockLevels {
levels: Record<LevelType, PriceLevel[]>
close: number | null
summary: string
symbol: string
/** dates 与 series 对齐;前端按自身 rows 的日期映射,缺失填 null */
dates?: string[]
series?: LevelSeries
}
export interface AiStockReport {
id: string
symbol: string
name: string
focus: string
content: string
summary?: string
close?: number | null
levels?: Record<LevelType, PriceLevel[]>
created_at: string
}
// ===== Kline =====
export interface MinuteKlineRow {
datetime: string
@@ -187,6 +197,7 @@ export interface WatchlistEntry {
symbol: string
added_at: string
note?: string
name?: string | null
}
export interface Quote {
@@ -308,6 +319,26 @@ export interface OverviewMarket {
industry_rank: { leading: OverviewDimensionRankItem[]; lagging: OverviewDimensionRankItem[] }
}
// ===== 概念涨幅轮动矩阵 =====
// dates: 日期字符串列表(最新在最前); columns: {日期: [[概念名, 涨幅小数], ...]} 每列各自降序
export interface RpsRotationData {
dates: string[]
columns: Record<string, [string, number][]>
concept_count: number
}
// ===== 大盘复盘 =====
export interface AiReviewReport {
id: string
as_of: string
focus?: string
content: string
summary?: string
emotion_score?: number | null
emotion_label?: string
created_at: string
}
// ===== Strategy Engine =====
export interface StrategyParamDef {
id: string
@@ -334,6 +365,7 @@ export interface StrategyDetail {
entry_signals: string[]
exit_signals: string[]
stop_loss: number | null
take_profit: number | null
trailing_stop: number | null
trailing_take_profit_activate: number | null
trailing_take_profit_drawdown: number | null
@@ -377,12 +409,12 @@ export interface MonitorRule {
id: string
name: string
enabled: boolean
type: 'strategy' | 'signal' | 'price' | 'market'
type: 'strategy' | 'signal' | 'price' | 'market' | 'ladder'
scope: 'symbols' | 'all' | 'sector'
symbols: string[]
sector?: string | null
strategy_id?: string | null
direction: 'entry' | 'exit' | 'both'
direction: 'entry' | 'exit' | 'both' | 'up' | 'down'
conditions: MonitorCondition[]
logic: 'and' | 'or'
cooldown_seconds: number
@@ -391,6 +423,9 @@ export interface MonitorRule {
webhook_url?: string
webhook_enabled?: boolean
created_at?: string
// ladder 专属: 封单监控
metric?: 'sealed_vol' | 'sealed_amount' // 量(手) / 额(元)
threshold?: number // 封单 <= 此值时报警
}
export interface MonitorRuleOptions {
@@ -419,6 +454,8 @@ export interface AlertEvent {
signals?: string[]
severity?: string
strategy_id?: string
conditions?: MonitorCondition[]
logic?: 'and' | 'or'
}
/** 生成监控规则 id (时间戳 + 随机后缀), 用户无需手动填写。 */
@@ -560,6 +597,7 @@ export interface StrategyBacktestResult {
entry_signals: string[]
exit_signals: string[]
stop_loss: number | null
take_profit: number | null
trailing_stop: number | null
trailing_take_profit_activate: number | null
trailing_take_profit_drawdown: number | null
@@ -574,7 +612,7 @@ export interface StrategyBacktestResult {
// ===== Settings =====
/** 端点发现清单 —— 对应数据源官网 /endpoints.json */
/** 端点发现清单 —— 对应 tickflow.org/endpoints.json */
export interface EndpointItem {
id: string
url: string
@@ -611,11 +649,13 @@ export interface SettingsState {
ai_base_url: string
ai_api_key_masked: string
has_ai_key: boolean
ai_configured?: boolean
ai_model: string
ai_daily_token_budget: number
ai_codex_command?: string
ai_user_agent: string
}
/** 保存数据源 Key 的响应(先探后存) */
/** 保存 TickFlow Key 的响应(先探后存) */
export interface SaveTickflowKeyResult {
ok: boolean
/** ok=false 且 key 无效时的原因标识,前端据此提示「Key 无效」 */
@@ -633,6 +673,20 @@ export interface Preferences {
indices_nav_pinned: boolean
minute_sync_enabled: boolean
minute_sync_days: number
daily_data_provider?: string
adj_factor_provider?: string
minute_data_provider?: string
realtime_data_provider?: string
realtime_watchlist_symbols?: string[]
realtime_pull_stock?: boolean
realtime_pull_etf?: boolean
realtime_pull_index?: boolean
realtime_index_mode?: 'core' | 'all'
realtime_index_symbols?: string[]
pipeline_pull_a_share: boolean
pipeline_pull_etf: boolean
pipeline_pull_index: boolean
pipeline_index_symbols: string
pipeline_schedule: { hour: number; minute: number }
instruments_schedule: { hour: number; minute: number }
enriched_batch_size: number
@@ -640,10 +694,15 @@ export interface Preferences {
limit_ladder_monitor_enabled: boolean
depth_polling_interval: number
depth_finalize_time: { hour: number; minute: number }
review_schedule: { enabled: boolean; hour: number; minute: number }
review_push_channels: string[]
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
system_notify_enabled: boolean
feishu_webhook_url?: string
feishu_webhook_secret?: string
webhook_enabled_default?: boolean
sidebar_index_symbols: string[]
nav_order: string[]
nav_hidden: string[]
@@ -667,27 +726,26 @@ export interface StrategyAlertEvent {
export const api = {
health: () => request<{ status: string; version: string; mode: string }>('/health'),
verifyAccessUuid: (credential: string) =>
request<VerifyAccessUuidResponse>('/api/auth/verify', {
// ===== Auth (访问认证) =====
authStatus: () =>
request<{ configured: boolean; authenticated: boolean }>('/api/auth/status'),
authSetup: (password: string) =>
request<{ ok: boolean }>('/api/auth/setup', {
method: 'POST',
body: JSON.stringify({ credential }),
body: JSON.stringify({ password }),
}),
accessAuthStatus: () => request<AccessAuthStatusResponse>('/api/auth/status'),
// ===== Admin UUID management =====
listUuids: () => request<UuidRecord[]>('/api/admin/uuids'),
createUuid: (label = '') =>
request<UuidRecord>('/api/admin/uuids', {
authLogin: (password: string) =>
request<{ ok: boolean }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ label }),
body: JSON.stringify({ password }),
}),
authLogout: () =>
request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }),
authChangePassword: (oldPassword: string, newPassword: string) =>
request<{ ok: boolean }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
}),
deleteUuid: (uuid: string) =>
request<{ ok: boolean }>(`/api/admin/uuids/${encodeURIComponent(uuid)}`, { method: 'DELETE' }),
toggleUuid: (uuid: string, enabled: boolean) =>
request<{ ok: boolean }>(
`/api/admin/uuids/${encodeURIComponent(uuid)}/toggle?enabled=${enabled}`,
{ method: 'PUT' },
),
settings: () => request<SettingsState>('/api/settings'),
saveTickflowKey: (api_key: string) =>
@@ -705,23 +763,46 @@ export const api = {
),
/** 保存 AI 配置 */
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; daily_token_budget?: number }) =>
request<{ ok: boolean }>('/api/settings/ai', {
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; codex_command?: string; user_agent?: string }) =>
request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_codex_command?: string; ai_configured?: boolean }>('/api/settings/ai', {
method: 'POST',
body: JSON.stringify(ai),
}),
/** 一键清空 AI 配置(保留自定义 UA) */
clearAiSettings: () =>
request<{ ok: boolean }>('/api/settings/ai', { method: 'DELETE' }),
preferences: () => request<Preferences>('/api/settings/preferences'),
updateMinuteSync: (enabled: boolean, days: number) =>
request<Preferences>('/api/settings/preferences/minute-sync', {
method: 'PUT',
body: JSON.stringify({ minute_sync_enabled: enabled, minute_sync_days: days }),
}),
updatePipelinePullTypes: (cfg: Partial<Pick<Preferences, 'pipeline_pull_a_share' | 'pipeline_pull_etf' | 'pipeline_pull_index'>>) =>
request<{
pipeline_pull_a_share: boolean
pipeline_pull_etf: boolean
pipeline_pull_index: boolean
}>('/api/settings/preferences/pipeline-pull-types', {
method: 'PUT',
body: JSON.stringify(cfg),
}),
updatePipelineIndexSymbols: (symbols: string) =>
request<{ pipeline_index_symbols: string }>('/api/settings/preferences/pipeline-index-symbols', {
method: 'PUT',
body: JSON.stringify({ symbols }),
}),
updateRealtimeQuotes: (enabled: boolean) =>
request<{ realtime_quotes_enabled: boolean }>('/api/settings/preferences/realtime-quotes', {
request<{ realtime_quotes_enabled: boolean; realtime_allowed?: boolean; mode?: string; error?: string }>('/api/settings/preferences/realtime-quotes', {
method: 'PUT',
body: JSON.stringify({ realtime_quotes_enabled: enabled }),
}),
updateRealtimeQuoteScope: (cfg: Partial<Pick<Preferences, 'realtime_pull_stock' | 'realtime_pull_etf' | 'realtime_pull_index' | 'realtime_index_mode' | 'realtime_index_symbols'>>) =>
request<Partial<Preferences>>('/api/settings/preferences/realtime-quote-scope', {
method: 'PUT',
body: JSON.stringify(cfg),
}),
updateIndicesNavPinned: (pinned: boolean) =>
request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', {
method: 'PUT',
@@ -731,9 +812,13 @@ export const api = {
request<{
enabled: boolean
running: boolean
mode?: 'none' | 'watchlist' | 'full_market'
realtime_allowed?: boolean
interval_s: number
symbol_count: number
watchlist_symbol_count?: number
index_symbol_count?: number
etf_symbol_count?: number
quote_age_ms: number | null
is_trading_hours: boolean
last_fetch_ms: number | null
@@ -774,11 +859,31 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ enabled }),
}),
updateFeishuWebhook: (url: string, secret: string = '') =>
request<{ feishu_webhook_url: string; feishu_webhook_secret: string }>('/api/settings/preferences/feishu-webhook', {
method: 'PUT',
body: JSON.stringify({ url, secret }),
}),
updateWebhookDefault: (enabled: boolean) =>
request<{ webhook_enabled_default: boolean }>('/api/settings/preferences/webhook-enabled-default', {
method: 'PUT',
body: JSON.stringify({ enabled }),
}),
updatePipelineSchedule: (hour: number, minute: number) =>
request<{ hour: number; minute: number }>('/api/settings/preferences/pipeline-schedule', {
method: 'PUT',
body: JSON.stringify({ hour, minute }),
}),
updateReviewSchedule: (enabled: boolean, hour: number, minute: number) =>
request<{ enabled: boolean; hour: number; minute: number }>('/api/settings/preferences/review-schedule', {
method: 'PUT',
body: JSON.stringify({ enabled, hour, minute }),
}),
updateReviewPush: (channels: string[]) =>
request<{ review_push_channels: string[] }>('/api/settings/preferences/review-push', {
method: 'PUT',
body: JSON.stringify({ channels }),
}),
updateDepthPollingInterval: (interval: number) =>
request<{ depth_polling_interval: number }>('/api/settings/preferences/depth-polling-interval', {
method: 'PUT',
@@ -960,6 +1065,11 @@ export const api = {
`/api/watchlist/${encodeURIComponent(symbol)}`,
{ method: 'DELETE' },
),
watchlistMoveToTop: (symbol: string) =>
request<{ symbols: WatchlistEntry[] }>(
`/api/watchlist/${encodeURIComponent(symbol)}/top`,
{ method: 'POST' },
),
watchlistClear: () =>
request<{ removed: number }>('/api/watchlist', { method: 'DELETE' }),
watchlistQuotes: () => request<{ quotes: Quote[] }>('/api/watchlist/quotes'),
@@ -995,6 +1105,10 @@ export const api = {
request<{ as_of: string | null; rows: MarketSnapshotRow[] }>('/api/screener/market-snapshot'),
overviewMarket: (asOf?: string) => request<OverviewMarket>(`/api/overview/market${asOf ? `?as_of=${asOf}` : ''}`),
// 概念涨幅轮动矩阵: 每列(日期)各自把所有概念按当天涨幅从高到低排序
rpsRotation: (days: number) =>
request<RpsRotationData>(`/api/rps/rotation?days=${days}`),
limitLadder: (asOf?: string, extColumns?: string, direction?: 'up' | 'down') => {
const params = new URLSearchParams()
if (asOf) params.set('as_of', asOf)
@@ -1095,7 +1209,7 @@ export const api = {
},
),
// 端点发现 —— 后端代理拉取数据源官网 /endpoints.json(前端无法跨域直连)
// 端点发现 —— 后端代理拉取 tickflow.org/endpoints.json(前端无法跨域直连)
listEndpoints: () =>
request<EndpointManifest>('/api/settings/endpoints'),
@@ -1196,6 +1310,13 @@ export const api = {
{ method: 'POST' },
),
// 内置预设 (概念/行业) 手动获取数据: 走结构转换, 保证 schema 一致
extDataPresetFetch: (id: string) =>
request<{ status: string; rows: number }>(
`/api/ext-data/presets/${id}/fetch`,
{ method: 'POST' },
),
extDataDetectFields: (file: File) => {
const fd = new FormData()
fd.append('file', file)
@@ -1235,11 +1356,256 @@ export const api = {
`/api/financials/cash-flow${symbol ? `?symbol=${encodeURIComponent(symbol)}` : ''}`,
),
/** 触发财务数据同步(后台异步执行,接口立即返回 started 状态) */
financialSync: (table: string) =>
request<{ status: string; synced: Record<string, number> }>(
request<{ status: string; synced: { started: boolean; reason?: string } }>(
`/api/financials/sync/${table}`, { method: 'POST' },
),
/** AI 分析报告 CRUD */
financialReportsList: () =>
request<{ reports: AiFinancialReport[] }>('/api/financials/reports'),
financialReportSave: (r: {
symbol: string; name?: string; focus?: string; content: string
periods?: number; summary?: string
}) =>
request<{ ok: boolean; report: AiFinancialReport }>('/api/financials/reports', {
method: 'POST', body: JSON.stringify(r),
}),
financialReportDelete: (reportId: string) =>
request<{ ok: boolean }>(`/api/financials/reports/${encodeURIComponent(reportId)}`, { method: 'DELETE' }),
/**
* AI 财务分析 — 流式调用。
*
* 返回一个可逐行读取的 async generator,每行是 JSON:
* {type:"meta",symbol,summary,periods}
* {type:"delta",content:"..."} ← 文本片段,逐个累加
* {type:"error",message:"..."}
* {type:"done"}
*
* 用 ReadableStream 解析(而非 SSE EventSource),支持 POST body 且更简单。
*/
async *financialAnalyzeStream(symbol: string, focus?: string): AsyncGenerator<{
type: 'meta' | 'delta' | 'error' | 'done'
symbol?: string
summary?: string
periods?: number
content?: string
message?: string
}> {
const res = await fetch('/api/financials/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbol, focus: focus ?? '' }),
})
if (!res.ok) {
let detail = ''
try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
const msg = detail || `${res.status} ${res.statusText}`
toast(msg, 'error')
throw new Error(msg)
}
if (!res.body) throw new Error('响应无 body')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
// 按行分割(保留最后不完整的行在 buf)
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
const s = line.trim()
if (!s) continue
try {
yield JSON.parse(s)
} catch {
// 忽略无法解析的行
}
}
}
// 处理残余
if (buf.trim()) {
try { yield JSON.parse(buf.trim()) } catch { /* ignore */ }
}
},
// ===== 个股分析 =====
stockAnalysisLevels: (symbol: string, days = 120) =>
request<StockLevels>(`/api/stock-analysis/levels?symbol=${encodeURIComponent(symbol)}&days=${days}`),
stockAnalysisReportsList: () =>
request<{ reports: AiStockReport[] }>('/api/stock-analysis/reports'),
stockAnalysisReportSave: (r: {
symbol: string; name?: string; focus?: string; content: string
summary?: string; close?: number | null
levels?: Record<LevelType, PriceLevel[]>
}) =>
request<{ ok: boolean; report: AiStockReport }>('/api/stock-analysis/reports', {
method: 'POST', body: JSON.stringify(r),
}),
stockAnalysisReportDelete: (reportId: string) =>
request<{ ok: boolean }>(`/api/stock-analysis/reports/${encodeURIComponent(reportId)}`, { method: 'DELETE' }),
/**
* AI 个股四维分析 — 流式调用(NDJSON,与财务分析同协议)。
* meta 里额外带 levels(关键价位)供图表回放。
*/
async *stockAnalyzeStream(symbol: string, focus?: string): AsyncGenerator<{
type: 'meta' | 'delta' | 'error' | 'done'
symbol?: string
summary?: string
levels?: Record<LevelType, PriceLevel[]>
close?: number | null
content?: string
message?: string
}> {
const res = await fetch('/api/stock-analysis/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbol, focus: focus ?? '' }),
})
if (!res.ok) {
let detail = ''
try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
const msg = detail || `${res.status} ${res.statusText}`
toast(msg, 'error')
throw new Error(msg)
}
if (!res.body) throw new Error('响应无 body')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
const s = line.trim()
if (!s) continue
try { yield JSON.parse(s) } catch { /* ignore */ }
}
}
if (buf.trim()) {
try { yield JSON.parse(buf.trim()) } catch { /* ignore */ }
}
},
// ===== 大盘复盘 =====
reviewReportsList: () =>
request<{ reports: AiReviewReport[] }>('/api/market-recap/reports'),
reviewReportSave: (r: {
as_of: string; focus?: string; content: string
summary?: string; emotion_score?: number | null; emotion_label?: string
}) =>
request<{ ok: boolean; report: AiReviewReport }>('/api/market-recap/reports', {
method: 'POST', body: JSON.stringify(r),
}),
reviewReportDelete: (reportId: string) =>
request<{ ok: boolean }>(`/api/market-recap/reports/${encodeURIComponent(reportId)}`, { method: 'DELETE' }),
/**
* AI 大盘复盘 — 流式调用(NDJSON,与个股/财务分析同协议)。
* meta 里带 as_of / emotion_score / emotion_label / summary,供前端先渲染信号灯。
*/
async *reviewStream(asOf?: string, focus?: string): AsyncGenerator<{
type: 'meta' | 'delta' | 'error' | 'done'
as_of?: string
emotion_score?: number
emotion_label?: string
summary?: string
content?: string
message?: string
}> {
const res = await fetch('/api/market-recap/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ as_of: asOf ?? null, focus: focus ?? '' }),
})
if (!res.ok) {
let detail = ''
try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
const msg = detail || `${res.status} ${res.statusText}`
toast(msg, 'error')
throw new Error(msg)
}
if (!res.body) throw new Error('响应无 body')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
const s = line.trim()
if (!s) continue
try { yield JSON.parse(s) } catch { /* ignore */ }
}
}
if (buf.trim()) {
try { yield JSON.parse(buf.trim()) } catch { /* ignore */ }
}
},
/** AI 概念轮动分析 — 流式 NDJSON。 */
async *rotationAnalyzeStream(days: number, focus?: string): AsyncGenerator<{
type: 'meta' | 'delta' | 'error' | 'done'
days?: number
summary?: string
content?: string
message?: string
}> {
const res = await fetch('/api/rps/rotation-analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days, focus: focus ?? '' }),
})
if (!res.ok) {
let detail = ''
try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
const msg = detail || `${res.status} ${res.statusText}`
toast(msg, 'error')
throw new Error(msg)
}
if (!res.body) throw new Error('响应无 body')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
const s = line.trim()
if (!s) continue
try { yield JSON.parse(s) } catch { /* ignore */ }
}
}
if (buf.trim()) {
try { yield JSON.parse(buf.trim()) } catch { /* ignore */ }
}
},
// ===== Strategy Engine =====
strategyList: () =>
request<{ strategies: StrategyDetail[] }>('/api/strategies'),
@@ -1307,6 +1673,34 @@ export const api = {
monitorRuleDelete: (id: string) =>
request<{ ok: boolean }>(`/api/monitor-rules/${encodeURIComponent(id)}`, { method: 'DELETE' }),
/** 模拟触发 ladder 封单监控 (Dev 调试, 不落盘不推送) */
monitorRuleTestLadder: () =>
request<{
ok: boolean
as_of: string
sealed_count: number
triggered: Array<{
rule_id: string; rule_name: string; symbol: string; name?: string
type: string; message: string; severity: string
sealed_value: number; sealed_metric: string
current_sealed_vol?: number; current_sealed_amount?: number
}>
not_triggered: Array<{
rule_id: string; rule_name: string; symbol: string
metric: string; threshold: number; current_value: number | null
current_sealed_vol?: number; current_sealed_amount?: number | null
reason: string
}>
}>('/api/monitor-rules/test-ladder', { method: 'POST' }),
/** 真实触发 ladder 预警 (落盘+飞书+SSE), Dev 调试用 */
monitorRuleTriggerLadder: () =>
request<{
ok: boolean
triggered: number
events: Array<{ symbol: string; name: string; message: string }>
}>('/api/monitor-rules/trigger-ladder', { method: 'POST' }),
/** 生成演示监控规则 (Dev 页用) */
monitorRuleSeed: () =>
request<{ ok: boolean; generated: number }>('/api/monitor-rules/seed', { method: 'POST' }),
@@ -1334,11 +1728,11 @@ export const api = {
/** 检查 AI 配置状态 */
strategyAiStatus: () =>
request<{ configured: boolean; has_key: boolean; has_model: boolean }>('/api/strategies/ai/status'),
request<{ configured: boolean; has_key: boolean; has_model: boolean; provider?: string }>('/api/strategies/ai/status'),
/** 测试 AI 连通性 */
strategyAiTest: () =>
request<{ ok: boolean; error?: string; model?: string; usage?: { prompt: number; completion: number } }>(
request<{ ok: boolean; error?: string; model?: string; response?: string; usage?: { prompt: number; completion: number } }>(
'/api/strategies/ai/test',
{ method: 'POST' },
),
@@ -1408,6 +1802,9 @@ export interface DataStatus {
index_daily: TableStats | null
index_enriched: TableStats | null
index_instruments: InstrumentsStats | null
etf_daily: TableStats | null
etf_enriched: TableStats | null
etf_instruments: InstrumentsStats | null
minute: TableStats | null
adj_factor: TableStats | null
instruments: InstrumentsStats | null
@@ -1423,6 +1820,14 @@ export interface DataStatus {
index_enriched_size_mb?: number
index_instruments_files?: number
index_instruments_size_mb?: number
etf_daily_files?: number
etf_daily_size_mb?: number
etf_enriched_files?: number
etf_enriched_size_mb?: number
etf_instruments_files?: number
etf_instruments_size_mb?: number
etf_adj_factor_files?: number
etf_adj_factor_size_mb?: number
minute_files: number
minute_size_mb: number
adj_factor_files: number
@@ -1468,6 +1873,7 @@ export interface PullConfig {
last_status?: string | null
last_message?: string | null
last_rows?: number | null
next_run?: string | null
}
export interface ExtDataConfig {
+7 -7
View File
@@ -1,6 +1,6 @@
// capability 内部名 → 用户能理解的中文标签
export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'quote.by_symbol': { name: '实时行情(按标的)', hint: '查询单只股票当前价' },
'quote.by_symbol': { name: '自选股实时监控', hint: 'Free 可按标的查询实时行情,用于少量自选股监控' },
'quote.batch': { name: '实时行情(批量)', hint: '一次拿多只股票的价' },
'quote.pool': { name: '标的池查询', hint: '按沪深300等池子拿行情' },
'kline.daily.by_symbol': { name: '日 K(按标的)', hint: '单只股票历史日 K' },
@@ -16,7 +16,7 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
// none = 档(无 key / 无效 key),低于 free,仅历史日K无实时行情。
// none = None 档(无 key / 无效 key),低于 free,仅历史日K无实时行情。
export const TIER_RANK: Record<string, number> = { none: -1, free: 0, starter: 1, pro: 2, expert: 3 }
export const EXPERT_RANK = TIER_RANK.expert
@@ -45,19 +45,19 @@ const TIER_STYLE: Record<string, TierStyle> = {
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '基础日K · 单股查询',
desc: '历史日K · 自选实时',
tagBg: { background: 'rgba(113,113,122,0.3)' },
dotStyle: { background: '#71717a' },
labelTextStyle: { color: '#a1a1aa' },
},
starter: {
desc: '批量同步 · 行情池',
desc: '除权因子 · 全市场实时',
tagBg: { background: 'rgba(59,130,246,0.2)' },
dotStyle: { background: '#3b82f6' },
labelTextStyle: { color: '#60a5fa' },
},
pro: {
desc: '分钟K · 实时行情 · 盘口',
desc: '分钟K · 盘口',
tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' },
dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
@@ -92,8 +92,8 @@ export function tierTextStyle(label: string): { color?: string; background?: str
export function TierTag({ label, className = '' }: { label: string; className?: string }) {
const t = tierStyle(label)
const base = tierBaseName(label)
// none 档显示文「」,其余档显示英文档名
const display = base === 'none' ? '' : base
// none 档显示文「None」,其余档显示英文档名
const display = base === 'none' ? 'None' : base
return (
<span
className={`inline-flex h-[18px] max-w-[80px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none ${className}`}
-68
View File
@@ -1,68 +0,0 @@
import { useMemo } from 'react'
import { useTheme, type ResolvedTheme } from '@/components/ThemeProvider'
export interface ChartTheme {
/** 坐标轴文字 / 图例文字 */
text: string
/** 坐标轴线条 / 边框 */
axisLine: string
/** 网格线 / splitLine */
splitLine: string
/** 十字线 / axisPointer 线 */
crosshair: string
/** tooltip 背景 */
tooltipBg: string
/** tooltip 边框 */
tooltipBorder: string
/** tooltip 标题文字 */
tooltipTitle: string
/** tooltip 正文文字 */
tooltipText: string
/** dataZoom 背景 */
dataZoomBg: string
/** dataZoom 边框 */
dataZoomBorder: string
/** dataZoom 选中区域 */
dataZoomFiller: string
/** dataZoom 手柄 */
dataZoomHandle: string
}
const LIGHT: ChartTheme = {
text: '#52525B',
axisLine: '#E4E4E7',
splitLine: '#F4F4F5',
crosshair: 'rgba(0,0,0,0.12)',
tooltipBg: 'rgba(255,255,255,0.96)',
tooltipBorder: 'rgba(0,0,0,0.08)',
tooltipTitle: '#71717A',
tooltipText: '#18181B',
dataZoomBg: 'rgba(0,0,0,0.04)',
dataZoomBorder: 'rgba(0,0,0,0.08)',
dataZoomFiller: 'rgba(59,130,246,0.18)',
dataZoomHandle: '#A1A1AA',
}
const DARK: ChartTheme = {
text: '#A1A1AA',
axisLine: '#334155',
splitLine: '#1E293B',
crosshair: 'rgba(255,255,255,0.12)',
tooltipBg: 'rgba(15,23,42,0.95)',
tooltipBorder: 'rgba(148,163,184,0.2)',
tooltipTitle: '#94A3B8',
tooltipText: '#E2E8F0',
dataZoomBg: 'rgba(15,23,42,0.55)',
dataZoomBorder: 'rgba(148,163,184,0.18)',
dataZoomFiller: 'rgba(59,130,246,0.18)',
dataZoomHandle: '#64748B',
}
export function getChartTheme(resolved: ResolvedTheme): ChartTheme {
return resolved === 'dark' ? DARK : LIGHT
}
export function useChartTheme(): ChartTheme {
const { resolved } = useTheme()
return useMemo(() => getChartTheme(resolved), [resolved])
}
+7 -5
View File
@@ -20,7 +20,8 @@ const listeners = new Set<() => void>()
function readSeen(): number {
try {
const v = localStorage.getItem(STORAGE_KEY)
return v ? parseInt(v, 10) || 0 : -1 // -1 = 未初始化
if (v === null) return -1 // 从未设置过 → 未初始化
return parseInt(v, 10) || 0
} catch {
return -1
}
@@ -52,11 +53,12 @@ function getSnapshot() {
/** 轮询更新最新总数 (Layout 层调用)。 */
export function setCurrentTotal(total: number): void {
// undefined/null 视为未就绪, 不更新 (Layout 层已在传入前过滤)
if (total == null) return
// total=0 视为未初始化, 不更新 (避免渲染期 data=undefined 传 0 重置 lastSeen)
if (total <= 0) return
// 首次初始化: 把已读基线设为当前总数 (包括 total=0, 避免未初始化时徽章 stuck 在 1)
if (lastSeenTotal < 0) {
// 首次初始化: lastSeen <= 0 (从未设置 -1, 或历史为 0) → 把已读基线设为当前总数
// 否则 lastSeen=0 + total=1 会被误算成"1条未读" (首次进入就显示徽标的 bug)
if (lastSeenTotal <= 0) {
lastSeenTotal = total
writeSeen(total)
}
+8 -1
View File
@@ -50,6 +50,7 @@ export const QK = {
// Kline
kline: (symbol: string, start: string, end: string, extColumns?: string) =>
['kline', symbol, start, end, extColumns ?? ''] as const,
stockLevels: (symbol: string, days?: number) => ['stock-levels', symbol, days ?? 120] as const,
klineMinute: (symbol: string, date: string) =>
['kline-minute', symbol, date] as const,
indexDaily: (symbol: string, start: string, end: string) =>
@@ -69,6 +70,12 @@ export const QK = {
monitorRules: ['monitor-rules'] as const,
monitorRuleOptions: ['monitor-rule-options'] as const,
alerts: (source?: string) => ['alerts', source ?? ''] as const,
// AI 大盘复盘
reviewReports: ['review-reports'] as const,
// 概念涨幅轮动矩阵
rpsRotation: (days: number) => ['rps-rotation', days] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
@@ -80,5 +87,5 @@ export const SSE_INVALIDATE_PREFIXES = [
'index-quotes',
'overview-market',
'limit-ladder',
'screener-cached',
'screener',
] as const
+220
View File
@@ -0,0 +1,220 @@
/**
* 复盘生成状态的全局单例 store —— 脱离 Review 组件生命周期。
*
* 解决的问题:生成中切换到其他页面,Review 组件卸载会丢失 phase/content。
* 本 store 把流式生成的状态提到模块级,组件卸载后流仍在后台继续跑,
* 回到页面订阅即可恢复显示。
*
* 设计:
* - 模块级 state(phase/content/meta/focus),唯一的生成实例
* - AbortController 存模块级 ref,组件卸载不中断流
* - 订阅者列表(notify 机制),Review mount 时订阅、unmount 时退订
*/
import { api } from '@/lib/api'
export type ReviewPhase = 'idle' | 'loading' | 'streaming' | 'done' | 'error'
export interface ReviewMeta {
as_of?: string
emotion_score?: number
emotion_label?: string
summary?: string
}
export interface ReviewState {
phase: ReviewPhase
content: string
error: string
meta: ReviewMeta | null
focus: string
}
const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null, focus: '' }
// ===== 模块级单例状态(组件卸载不销毁)=====
let state: ReviewState = { ...INITIAL }
let abortCtrl: AbortController | null = null
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
// 用于区分两条流, 避免互相丢弃事件或重复归档。
let generatingSource: 'manual' | 'sse' | null = null
// ===== 订阅机制 =====
type Listener = () => void
const listeners = new Set<Listener>()
function notify() {
for (const l of listeners) l()
}
export function getReviewState(): ReviewState {
return state
}
export function subscribeReview(listener: Listener): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
// 暴露给组件直接读取最新 meta(用于自动归档,避免闭包取旧值)
export function getReviewMeta(): ReviewMeta | null {
return state.meta
}
/** 是否正在生成(loading 或 streaming) */
export function isReviewGenerating(): boolean {
return state.phase === 'loading' || state.phase === 'streaming'
}
/**
* 启动复盘生成。返回后流在后台独立运行,组件卸载不影响。
* @param asOf 复盘日期
* @param focus 用户追加的复盘关注点
* @param onDone 完成回调(供调用方做自动归档)
*/
export async function startReviewGeneration(
asOf: string | undefined,
focus: string,
onDone?: (fullContent: string, meta: ReviewMeta | null) => void,
): Promise<void> {
// 已在生成中,不重复启动
if (isReviewGenerating()) return
generatingSource = 'manual'
state = { phase: 'loading', content: '', error: '', meta: null, focus }
notify()
abortCtrl = new AbortController()
let buf = ''
let failed = false
let doneMeta: ReviewMeta | null = null
try {
for await (const evt of api.reviewStream(asOf, focus)) {
if (abortCtrl.signal.aborted) break
if (evt.type === 'meta') {
doneMeta = evt
state = { ...state, meta: evt }
notify()
} else if (evt.type === 'delta' && evt.content) {
buf += evt.content
state = { ...state, content: buf, phase: 'streaming' }
notify()
} else if (evt.type === 'error') {
failed = true
state = { ...state, error: evt.message ?? '复盘失败', phase: 'error' }
notify()
return
} else if (evt.type === 'done') {
state = { ...state, phase: 'done' }
notify()
}
}
// 流正常结束但无 done 事件,按 done 处理
if (buf && !failed) {
state = { ...state, phase: 'done' }
notify()
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里)
if (buf && !failed) {
onDone?.(buf, doneMeta)
}
}
} catch (e: any) {
if (!abortCtrl.signal.aborted) {
state = { ...state, error: e?.message ?? '复盘失败', phase: 'error' }
notify()
}
} finally {
abortCtrl = null
generatingSource = null
}
}
/** 中断当前生成(供"查看历史"等场景主动中断流)。 */
export function abortReviewGeneration(): void {
abortCtrl?.abort()
abortCtrl = null
}
/** 设置当前查看的历史报告(把 store 状态切到 done + 该报告内容)。 */
export function setViewingReport(report: {
content: string
as_of?: string
emotion_score?: number | null
emotion_label?: string
summary?: string
}): void {
abortCtrl?.abort()
abortCtrl = null
state = {
phase: 'done',
content: report.content,
error: '',
meta: {
as_of: report.as_of,
emotion_score: report.emotion_score ?? undefined,
emotion_label: report.emotion_label,
summary: report.summary,
},
focus: state.focus,
}
notify()
}
/** 重置到 idle(清空当前显示)。 */
export function resetReview(): void {
state = { ...INITIAL }
notify()
}
/**
* 喂入一条来自 SSE 的复盘事件(定时生成时后端推来的)。
*
* 用途: 定时复盘在后端流式生成, 通过 /api/intraday/stream 的 review_progress 事件
* 把 meta/delta/done 等实时推给前端, 前端调本函数把事件写进 store ——
* 这样开着复盘页的用户能看到「边生成边显示」, 和手动点生成完全一致。
*
* 事件格式与 recap_market_stream 产出一致:
* {type:'meta'|'delta'|'error'|'done'|'retry', ...}
*
* 与手动生成的并发:
* - 若手动正在生成(isReviewGenerating), 忽略 SSE 事件(手动流优先, 避免冲突)。
* - done 带 archived=true(定时场景后端已归档): 不重复调归档接口, 仅切到 done 态。
* - retry: 后端 LLM 断流重试, 清空已累积内容重新开始。
*/
export function feedReviewEvent(evt: any): void {
if (!evt || typeof evt !== 'object') return
const t = evt.type
// 并发控制: 手动流进行中时, SSE 事件一律忽略(手动流优先, 避免两条流抢同一个 store)
// 但若当前是 SSE 流自己在跑(generatingSource==='sse'), 则正常处理后续事件
if (generatingSource === 'manual') return
if (t === 'meta') {
// 定时流的第一个事件: 标记来源为 sse, 进入 streaming 态, 重置 content
generatingSource = 'sse'
state = { phase: 'streaming', content: '', error: '', meta: evt, focus: '' }
notify()
} else if (t === 'delta' && evt.content) {
// 只有 sse 流进行中时才累积(防止 meta 丢失时的孤立 delta)
if (generatingSource !== 'sse') return
state = { ...state, content: state.content + evt.content, phase: 'streaming' }
notify()
} else if (t === 'retry') {
if (generatingSource !== 'sse') return
// 后端重试: 清空已累积内容, 等待新一轮 meta/delta
state = { ...state, content: '', phase: 'streaming' }
notify()
} else if (t === 'error') {
if (generatingSource !== 'sse') return
state = { ...state, error: evt.message ?? '复盘生成失败', phase: 'error' }
notify()
generatingSource = null
} else if (t === 'done') {
if (generatingSource !== 'sse') return
// 定时场景 done 带 archived=true: 后端已归档, 前端只切 done 态, 不调归档接口。
state = { ...state, phase: 'done' }
notify()
generatingSource = null
}
}
@@ -0,0 +1,272 @@
import { useSyncExternalStore } from 'react'
import { api, type PriceLevel, type LevelType } from './api'
/**
* AI 个股分析 —— 全局任务/报告 store(与 aiReportStore 解耦、并行存在)。
*
* 与财务分析 store 的区别:
* - 独立的 activeTasks / history 状态池(不共享 3 并发上限)
* - meta 额外带 levels(关键价位),供图表回放
* - 状态文案在胶囊组件里用「蓝」色系区分(财务用紫)
*
* 设计同 aiReportStore:流式接收逻辑在此,与弹窗解耦 → 关闭弹窗后台照常累积。
*/
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
export interface ActiveTask {
id: string
symbol: string
name: string
focus: string
phase: Phase
content: string
error: string
meta: {
summary?: string
levels?: Record<LevelType, PriceLevel[]>
close?: number | null
} | null
createdAt: number
savedReportId?: string
doneAt?: number
dismissed?: boolean
}
export interface HistoryReport {
id: string
symbol: string
name: string
focus: string
content: string
summary?: string
close?: number | null
levels?: Record<LevelType, PriceLevel[]>
created_at: string
}
const MAX_ACTIVE = 3
let activeTasks: ActiveTask[] = []
let history: HistoryReport[] = []
let historyLoaded = false
const listeners = new Set<() => void>()
let activeDialogTaskId: string | null = null
let dialogMinimized = false
function emit() { listeners.forEach(fn => fn()) }
function subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn) } }
function normalizeAiError(msg: string) {
return msg.includes('API Key') || msg.includes('api_key')
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
: msg
}
let _activeSnap: ActiveTask[] = []
let _historySnap: HistoryReport[] = []
interface DialogSnap { taskId: string | null; minimized: boolean }
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
function rebuildSnap() {
_activeSnap = activeTasks
_historySnap = history
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
}
function getActiveSnapshot() { return _activeSnap }
function getHistorySnapshot() { return _historySnap }
function getDialogSnapshot() { return _dialogSnap }
function patchTask(id: string, patch: Partial<ActiveTask>) {
activeTasks = activeTasks.map(t => {
if (t.id !== id) return t
const next = { ...t, ...patch }
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
next.doneAt = Date.now()
}
return next
})
rebuildSnap()
emit()
}
// ===== 查询 hooks =====
export function useBubbleTasks(): ActiveTask[] {
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
const ds = _dialogSnap
return all.filter(t => {
if (t.phase === 'loading' || t.phase === 'streaming') {
return !(ds.taskId === t.id && !ds.minimized)
}
if (t.dismissed) return false
if (!ds.minimized && ds.taskId === t.id) return false
return true
})
}
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
return { reports, loaded: historyLoaded }
}
export function useDialogState() {
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
}
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
const ds = useDialogState()
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
if (!ds.taskId) return { task: null, mode: null }
if (ds.taskId.startsWith('history:')) {
const rid = ds.taskId.slice('history:'.length)
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
}
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
}
// ===== 动作 =====
export async function loadHistory(): Promise<void> {
try {
const res = await api.stockAnalysisReportsList()
history = res.reports ?? []
historyLoaded = true
rebuildSnap()
emit()
} catch { /* 静默 */ }
}
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
if (!historyLoaded) await loadHistory()
return history.find(r => r.symbol === symbol) ?? null
}
/**
* 查询某只股票【当日】是否已生成过分析报告(用于二次确认)。
* 判断依据:created_at 的日期部分 == 本地今天。
* @returns 当天最近一条报告,或 null
*/
export async function findTodayReport(symbol: string): Promise<HistoryReport | null> {
if (!historyLoaded) await loadHistory()
const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
return history.find(r => r.symbol === symbol && (r.created_at ?? '').slice(0, 10) === today) ?? null
}
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
if (existing) {
activeDialogTaskId = existing.id
dialogMinimized = false
rebuildSnap()
emit()
return { id: existing.id }
}
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
if (ongoing.length >= MAX_ACTIVE) {
return { error: `同时进行的个股分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
}
const id = `stask_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
const task: ActiveTask = {
id, symbol, name, focus,
phase: 'loading', content: '', error: '',
meta: null, createdAt: Date.now(),
}
activeTasks = [...activeTasks, task]
activeDialogTaskId = id
dialogMinimized = false
rebuildSnap()
emit()
runStream(id, symbol, name, focus)
return { id }
}
async function runStream(id: string, symbol: string, _name: string, focus: string) {
try {
let firstDelta = true
for await (const chunk of api.stockAnalyzeStream(symbol, focus)) {
const cur = activeTasks.find(t => t.id === id)
if (!cur) return
switch (chunk.type) {
case 'meta':
patchTask(id, { meta: { summary: chunk.summary, levels: chunk.levels, close: chunk.close } })
break
case 'delta':
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
patchTask(id, { content: cur.content + (chunk.content ?? '') })
break
case 'error':
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
return
case 'done':
patchTask(id, { phase: 'done' })
break
}
}
const final = activeTasks.find(t => t.id === id)
if (final && final.phase !== 'error') {
// 兜底:流正常结束但从未收到 delta(后端在生成内容前异常断流)→ 标记失败,避免卡死
if (!final.content) {
patchTask(id, { phase: 'error', error: '分析未返回内容(后端可能异常中断),请重试' })
return
}
try {
const res = await api.stockAnalysisReportSave({
symbol: final.symbol, name: final.name, focus: final.focus,
content: final.content, summary: final.meta?.summary ?? '',
close: final.meta?.close ?? null, levels: final.meta?.levels,
})
if (res.report) {
patchTask(id, { savedReportId: res.report.id })
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
historyLoaded = true
rebuildSnap()
emit()
}
} catch { /* 持久化失败不影响展示 */ }
}
} catch (e: any) {
const msg = String(e?.message ?? '分析失败')
patchTask(id, {
phase: 'error',
error: normalizeAiError(msg),
})
}
}
export function openDialog(taskId: string) {
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
}
export function minimizeDialog() {
dialogMinimized = true; rebuildSnap(); emit()
}
export function closeDialog() {
activeDialogTaskId = null; dialogMinimized = false; rebuildSnap(); emit()
}
export function restoreDialog(taskId: string) {
const t = activeTasks.find(x => x.id === taskId)
if (t && (t.phase === 'done' || t.phase === 'error')) {
patchTask(taskId, { dismissed: true })
}
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
}
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
return startAnalysis(task.symbol, task.name, task.focus)
}
export async function deleteReport(reportId: string): Promise<void> {
try {
await api.stockAnalysisReportDelete(reportId)
history = history.filter(r => r.id !== reportId)
rebuildSnap()
emit()
} catch { /* 静默 */ }
}
export function openHistoryReport(reportId: string) {
activeDialogTaskId = `history:${reportId}`; dialogMinimized = false; rebuildSnap(); emit()
}
+6 -3
View File
@@ -21,9 +21,6 @@ function kv<T>(key: string) {
}
export const storage = {
/** 主题偏好: light | dark | system */
theme: kv<'light' | 'dark' | 'system'>('tf-theme'),
/** 查询轮询 / SSE 配置 */
queryConfig: kv<unknown>('tf-stocks-query-config'),
@@ -94,6 +91,7 @@ export const storage = {
entryFill: 'close_t' | 'open_t+1'
exitFill: 'close_t' | 'open_t+1'
fees: string
slippage: string
maxPositions: string
maxExposure: string
initialCapital: string
@@ -110,4 +108,9 @@ export const storage = {
/** 行业分析页面字段配置 */
industryAnalysisConfig: kv<Record<string, any>>('industry-analysis-config'),
/** 数据页画像卡片显隐 (卡片key → 是否显示) */
dataCardVisible: kv<Record<string, boolean>>('data-card-visible'),
/** 数据页画像卡片顺序 (卡片key 数组, 长度=卡片总数) */
dataCardOrder: kv<string[]>('data-card-order'),
} as const
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useState } from 'react'
/**
* 记忆"上次查看的个股"(按页面维度,localStorage 持久化)。
*
* 两个分析页(财务 / 个股)各自独立记忆,key 区分:
* - financials: 最后查看的财务分析个股
* - stock-analysis: 最后查看的个股分析个股
*
* 用法:
* const { last, remember } = useLastStock('stock-analysis')
* remember('000001.SZ', '平安银行') // 选中股票时调用
* <LastStockChip stock={last} ... /> // 渲染在 PageHeader 右侧
*/
export interface StockRef { symbol: string; name: string }
const PREFIX = 'last_stock:'
export function useLastStock(scope: string) {
const [last, setLast] = useState<StockRef | null>(() => load(scope))
const remember = useCallback((symbol: string, name: string) => {
const ref = { symbol, name }
setLast(ref)
save(scope, ref)
}, [scope])
const clear = useCallback(() => {
setLast(null)
save(scope, null)
}, [scope])
return { last, remember, clear }
}
function load(scope: string): StockRef | null {
try {
const v = localStorage.getItem(PREFIX + scope)
if (!v) return null
const p = JSON.parse(v)
if (p && typeof p.symbol === 'string' && typeof p.name === 'string') return p
} catch { /* ignore */ }
return null
}
function save(scope: string, ref: StockRef | null) {
try {
if (ref) localStorage.setItem(PREFIX + scope, JSON.stringify(ref))
else localStorage.removeItem(PREFIX + scope)
} catch { /* ignore */ }
}
+17 -1
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { SSE_INVALIDATE_PREFIXES } from './queryKeys'
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
import { getQueryConfig } from './useQueryConfig'
import { toast } from '@/components/Toast'
import { pushAlertToasts } from '@/components/AlertToast'
import { feedReviewEvent } from './reviewStore'
import type { StrategyAlertEvent } from './api'
/**
@@ -110,6 +111,21 @@ export function useQuoteStream(
}
})
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
es.addEventListener('review_progress', (e: MessageEvent) => {
try {
const evt = JSON.parse(e.data)
feedReviewEvent(evt)
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
if (evt.type === 'done') {
qc.invalidateQueries({ queryKey: QK.reviewReports })
}
} catch {
// 忽略解析错误
}
})
es.onerror = () => {
es.close()
esRef.current = null
+14
View File
@@ -0,0 +1,14 @@
/**
* 订阅 reviewStore 的 React hook。
*
* mount 时订阅,store 变化触发 re-render;unmount 时退订(但 store 流继续跑)。
* 用 useSyncExternalStore 保证与 React 18 并发模式兼容。
*/
import { useSyncExternalStore } from 'react'
import {
getReviewState, subscribeReview, type ReviewState,
} from '@/lib/reviewStore'
export function useReviewState(): ReviewState {
return useSyncExternalStore(subscribeReview, getReviewState, getReviewState)
}
+12 -2
View File
@@ -34,12 +34,22 @@ export function usePreferences() {
})
}
/** 行情状态 — SSE quotes_updated 自动刷新 */
export function useQuoteStatus(opts?: { enabled?: boolean }) {
/** 行情状态 — SSE quotes_updated 自动刷新
* poll=true 时启用条件轮询兜底: 仅在非交易时段每 60s 轮询一次,
* 用于在交易时段边界 (11:30午休 / 12:55开盘 / 15:05收盘) 同步 is_trading_hours。
* 交易时段不轮询 (SSE 已驱动刷新), 非交易时段无 SSE 推送, 需要兜底。
* 只应在全局唯一挂载处 (Layout) 传 poll=true, 避免多页面重复轮询;
* 其他调用方共享同一 queryKey 缓存, 无需自行轮询。
*/
export function useQuoteStatus(opts?: { enabled?: boolean; poll?: boolean }) {
return useQuery({
queryKey: QK.quoteStatus,
queryFn: api.quoteStatus,
enabled: opts?.enabled ?? true,
refetchInterval: opts?.poll
? (query) => (query.state.data?.is_trading_hours ? false : 60_000)
: false,
})
}
+32 -7
View File
@@ -1,26 +1,51 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
import { router } from './router'
import { ThemeProvider } from './components/ThemeProvider'
import './index.css'
// 全局认证拦截: 任何 query/mutation 收到 401 (未登录/会话过期) → 跳登录页。
// api.ts 的 request() 已对 401 静默 (不弹 toast), 这里统一负责跳转。
// 排除 /login 自身的请求, 避免登录页请求失败又跳登录形成死循环。
const _redirectToLogin = (() => {
let redirecting = false
return (err: unknown) => {
if (redirecting) return
if (!(err instanceof Error)) return
const msg = err.message || ''
// 401 (未登录/会话过期) → 跳登录页
// 403 未初始化 (面板未设密码, 公网访问) → 也跳登录页(显示设密码提示)
const is401 = msg.includes('未登录') || msg.includes('会话已过期') || msg.includes('401')
const isNotInit = msg.includes('尚未初始化访问密码') || msg.includes('NOT_INITIALIZED')
if (!is401 && !isNotInit) return
// 已在登录页则不跳(避免死循环)
if (window.location.pathname === '/login') return
redirecting = true
const redirect = encodeURIComponent(window.location.pathname + window.location.search)
window.location.href = `/login?redirect=${redirect}`
}
})()
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (err) => _redirectToLogin(err),
}),
defaultOptions: {
queries: {
staleTime: 5_000, // 5s 内复用,与 §4.2 Repository 不变量一致
refetchOnWindowFocus: false,
},
mutations: {
onError: (err) => _redirectToLogin(err),
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>
)
-228
View File
@@ -1,228 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import {
Plus,
Copy,
Trash2,
ArrowLeft,
ToggleLeft,
ToggleRight,
Loader2,
Check,
AlertCircle,
} from 'lucide-react'
import { api } from '@/lib/api'
import { toast } from '@/components/Toast'
import { Logo } from '@/components/Logo'
const BRAND = '#8B5CF6'
function formatTime(ts: number) {
const d = new Date(ts * 1000)
return d.toLocaleString('zh-CN')
}
export function AdminUuids() {
const navigate = useNavigate()
const qc = useQueryClient()
const [label, setLabel] = useState('')
const [copied, setCopied] = useState<string | null>(null)
const { data: records = [], isLoading } = useQuery({
queryKey: ['admin-uuids'],
queryFn: () => api.listUuids(),
})
const create = useMutation({
mutationFn: () => api.createUuid(label),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-uuids'] })
setLabel('')
toast('已创建新 UUID', 'success')
},
onError: (err: any) => toast(err?.message || '创建失败', 'error'),
})
const toggle = useMutation({
mutationFn: ({ uuid, enabled }: { uuid: string; enabled: boolean }) =>
api.toggleUuid(uuid, enabled),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-uuids'] }),
onError: (err: any) => toast(err?.message || '操作失败', 'error'),
})
const remove = useMutation({
mutationFn: (uuid: string) => api.deleteUuid(uuid),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-uuids'] })
toast('已删除', 'success')
},
onError: (err: any) => toast(err?.message || '删除失败', 'error'),
})
const handleCopy = async (uuid: string) => {
try {
await navigator.clipboard.writeText(uuid)
setCopied(uuid)
setTimeout(() => setCopied(null), 1500)
} catch {
toast('复制失败', 'error')
}
}
return (
<div className="relative min-h-screen bg-base overflow-hidden flex flex-col">
{/* 背景光晕 */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-40 -left-40 h-[28rem] w-[28rem] rounded-full blur-[120px] opacity-20"
style={{ background: `radial-gradient(circle, ${BRAND}, transparent 70%)` }}
/>
<div
className="absolute -bottom-40 -right-32 h-[26rem] w-[26rem] rounded-full blur-[120px] opacity-15"
style={{ background: 'radial-gradient(circle, hsl(var(--accent)), transparent 70%)' }}
/>
</div>
{/* 顶栏 */}
<header className="relative z-10 flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2.5 text-foreground">
<Logo
size={24}
className="shrink-0"
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
/>
<span className="text-sm font-semibold tracking-tight">Stock Panel</span>
</div>
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-btn text-sm text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
</header>
{/* 内容 */}
<main className="relative z-10 flex-1 px-6 py-10">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="max-w-3xl mx-auto"
>
<div className="rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-foreground">访 UUID </h1>
<p className="mt-1 text-sm text-secondary">访 UUID</p>
</div>
</div>
{/* 创建区 */}
<div className="mt-6 flex items-center gap-3">
<input
type="text"
placeholder="备注(可选)"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="flex-1 px-3 py-2 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
<button
onClick={() => create.mutate()}
disabled={create.isPending}
className="inline-flex items-center gap-2 px-4 h-10 rounded-xl bg-accent text-white text-sm font-semibold hover:bg-accent/90 disabled:opacity-60 transition-all"
>
{create.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
UUID
</button>
</div>
{/* 列表 */}
<div className="mt-6 border border-border rounded-card overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-accent" />
</div>
) : records.length === 0 ? (
<div className="py-12 text-center text-sm text-secondary">
UUID,
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-elevated/50 text-secondary">
<tr>
<th className="text-left px-4 py-2.5 font-medium">UUID</th>
<th className="text-left px-4 py-2.5 font-medium"></th>
<th className="text-left px-4 py-2.5 font-medium"></th>
<th className="text-left px-4 py-2.5 font-medium"></th>
<th className="text-right px-4 py-2.5 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{records.map((r) => (
<tr key={r.uuid} className="hover:bg-elevated/30 transition-colors">
<td className="px-4 py-3 font-mono text-xs text-foreground">
<div className="flex items-center gap-2">
<span className="truncate max-w-[12rem]">{r.uuid}</span>
<button
onClick={() => handleCopy(r.uuid)}
className="text-muted hover:text-accent transition-colors"
title="复制"
>
{copied === r.uuid ? (
<Check className="h-3.5 w-3.5 text-bull" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
</td>
<td className="px-4 py-3 text-secondary">{r.label || '—'}</td>
<td className="px-4 py-3 text-secondary">{formatTime(r.created_at)}</td>
<td className="px-4 py-3">
<button
onClick={() => toggle.mutate({ uuid: r.uuid, enabled: !r.enabled })}
disabled={toggle.isPending}
className="text-muted hover:text-accent transition-colors"
title={r.enabled ? '禁用' : '启用'}
>
{r.enabled ? (
<ToggleRight className="h-5 w-5 text-bull" />
) : (
<ToggleLeft className="h-5 w-5 text-muted" />
)}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => remove.mutate(r.uuid)}
disabled={remove.isPending}
className="inline-flex items-center gap-1 text-xs text-danger hover:text-danger/80 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="mt-4 flex items-start gap-2 text-xs text-secondary">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>提示:禁用 UUID , UUID 访,</span>
</div>
</div>
</motion.div>
</main>
</div>
)
}
+195
View File
@@ -0,0 +1,195 @@
/**
* 访问认证页 — 复用同一组件处理「首次设密码」和「登录」两种状态。
*
* 根据后端 /api/auth/status 的 configured 字段决定显示:
* - configured=false → 显示「设置访问密码」(首次)
* - configured=true → 显示「登录」
*
* 安全:
* - 设密码接口后端限本机/内网; 公网用户设密码会被 403 拒绝, 页面据此提示。
* - 登录失败由后端限流(5次锁5分钟), 429 时前端显示等待提示。
*/
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Eye, EyeOff, Loader2, Lock, ShieldCheck, ShieldAlert, Sparkles } from 'lucide-react'
import { api } from '@/lib/api'
import { Logo } from '@/components/Logo'
import { cn } from '@/lib/cn'
export function Auth() {
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
const [showPwd, setShowPwd] = useState(false)
const [localError, setLocalError] = useState('')
// 取认证状态(是否已设密码)
const [status, setStatus] = useState<{ configured: boolean } | null>(null)
useEffect(() => {
api.authStatus().then(s => {
setStatus(s)
// 已登录的话直接进面板(避免登录页死循环)
if (s.authenticated) navigate('/', { replace: true })
}).catch(() => setStatus({ configured: false }))
}, [navigate])
const isSetup = !status?.configured // configured=false → 设密码模式
// 登录 / 设密码 共用一个 mutation(按 isSetup 调不同接口)
const submitMut = useMutation({
mutationFn: async () => {
if (isSetup) {
return api.authSetup(password)
}
return api.authLogin(password)
},
onSuccess: () => {
// 成功: 跳回原页面(或首页)
const redirect = new URLSearchParams(window.location.search).get('redirect') || '/'
navigate(redirect, { replace: true })
},
onError: (err: any) => {
const msg = err?.message || (isSetup ? '设置失败' : '登录失败')
// 设密码/登录失败必须显示: 401(密码错)/403(公网设密码被拒)/429(限流) 都要提示
setLocalError(msg)
},
})
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
setLocalError('')
if (isSetup) {
if (password.length < 6) { setLocalError('密码至少 6 位'); return }
if (password !== confirmPassword) { setLocalError('两次密码不一致'); return }
}
submitMut.mutate()
}
if (!status) {
return (
<div className="flex min-h-screen items-center justify-center bg-base">
<Loader2 className="h-6 w-6 animate-spin text-muted" />
</div>
)
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-base px-4">
{/* 背景辉光(与 Onboarding 风格一致) */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(139,92,246,0.15),transparent_40%),radial-gradient(circle_at_70%_80%,rgba(59,130,246,0.12),transparent_40%)]" />
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="relative w-full max-w-sm"
>
{/* Logo */}
<div className="mb-6 flex flex-col items-center gap-2">
<Logo className="h-10 w-10" />
<h1 className="text-lg font-semibold text-foreground">TickFlow Stock Panel</h1>
</div>
<div className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
{/* 标题区: 图标 + 文案随模式切换 */}
<div className="mb-5 flex items-center gap-2.5">
<div className={cn(
'grid h-9 w-9 place-items-center rounded-lg',
isSetup ? 'bg-accent/15 text-accent' : 'bg-purple-500/15 text-purple-400',
)}>
{isSetup ? <ShieldCheck className="h-5 w-5" /> : <Lock className="h-5 w-5" />}
</div>
<div>
<div className="text-sm font-medium text-foreground">
{isSetup ? '设置访问密码' : '登录访问'}
</div>
<div className="text-[11px] text-muted">
{isSetup ? '首次使用, 请为面板设置访问密码' : '请输入访问密码以继续'}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
{/* 密码输入 */}
<div className="relative">
<input
type={showPwd ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="访问密码"
autoFocus
className="h-10 w-full rounded-btn border border-border bg-base px-3 pr-9 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
/>
<button
type="button"
onClick={() => setShowPwd(s => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted hover:text-foreground"
tabIndex={-1}
>
{showPwd ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{/* 确认密码(仅设密码模式) */}
{isSetup && (
<input
type={showPwd ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder="再次输入密码"
className="h-10 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
/>
)}
{/* 错误提示 */}
{(localError || submitMut.error) && (
<div className="flex items-start gap-1.5 rounded-btn bg-danger/10 px-3 py-2 text-[11px] text-danger">
<ShieldAlert className="mt-px h-3.5 w-3.5 shrink-0" />
<span>{localError}</span>
</div>
)}
<button
type="submit"
disabled={submitMut.isPending || !password}
className="inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-btn bg-accent text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
>
{submitMut.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin" /></>
) : (
<>{isSetup ? '设置并进入' : '登录'}</>
)}
</button>
</form>
{/* 提示: 设密码模式告知本机限制 */}
{isSetup && (
<div className="mt-3 space-y-1.5 text-[10px] leading-relaxed text-muted/70">
<p>
, 访
</p>
<p>
{' '}
<a
href="https://github.com/shy3130/tickflow-stock-panel/blob/main/docs/deploy-password.md"
target="_blank"
rel="noreferrer"
className="text-accent underline-offset-2 hover:underline"
>
访
</a>
</p>
</div>
)}
</div>
<div className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-muted/60">
<Sparkles className="h-3 w-3" />
·
</div>
</motion.div>
</div>
)
}
+7 -7
View File
@@ -25,12 +25,12 @@ interface Variant {
glow?: string // 名字下方的发光线条 hex
}
// 同一个名字 "Stock Panel" 在 4 种风格语言里的呈现
// 同一个名字 "TickFlow Stock Panel" 在 4 种风格语言里的呈现
// 长字符串自动用更小字号 + 更窄字距,免得撑爆卡片;但风格语言(字体/字重/配色/图标)保持不变
const VARIANTS: Variant[] = [
{
id: 'pulsar',
name: 'Stock Panel',
name: 'TickFlow Stock Panel',
tagline: 'A-SHARE · SIGNAL TERMINAL',
hint: '脉冲星、雷达波纹 — 青绿强调色,字重黑体,中等字距',
icon: RadioTower,
@@ -40,7 +40,7 @@ const VARIANTS: Variant[] = [
},
{
id: 'vanta',
name: 'Stock Panel',
name: 'TickFlow Stock Panel',
tagline: 'MARKET · INTELLIGENCE',
hint: 'Vantablack — 纯白单色,字重最重,字距最宽,monochrome 高级感',
icon: Square,
@@ -50,7 +50,7 @@ const VARIANTS: Variant[] = [
},
{
id: 'helix',
name: 'Stock Panel',
name: 'TickFlow Stock Panel',
tagline: 'QUANT · TERMINAL',
hint: 'DNA 螺旋 — 紫色强调,等宽字体,赛博朋克经典意象',
icon: GitFork,
@@ -60,7 +60,7 @@ const VARIANTS: Variant[] = [
},
{
id: 'aurora',
name: 'Stock Panel',
name: 'TickFlow Stock Panel',
tagline: 'A-SHARE · DASHBOARD',
hint: '极光 — 青色强调,细字优雅,适中字距,与涨跌语义色不冲突',
icon: Sparkles,
@@ -77,7 +77,7 @@ const MOCK_NAV = [
{ icon: History, label: '回测' },
{ icon: SignalIcon, label: '信号' },
{ icon: Eye, label: '监控' },
{ icon: FileText, label: '财务' },
{ icon: FileText, label: '财务分析' },
]
export function Branding() {
@@ -85,7 +85,7 @@ export function Branding() {
<>
<PageHeader
title="视觉风格预览"
subtitle="名字保持 Stock Panel,4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
subtitle="名字保持 TickFlow Stock Panel,4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
/>
<div className="px-8 py-6">
+63 -14
View File
@@ -1,12 +1,12 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
Activity,
Crown,
Database,
Layers3,
RefreshCw,
Repeat,
Search,
Settings2,
TrendingDown,
@@ -14,8 +14,9 @@ import {
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { AnalysisConfigDialog, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
@@ -240,6 +241,7 @@ export function ConceptAnalysis() {
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [showRps, setShowRps] = useState(false)
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
@@ -256,6 +258,21 @@ export function ConceptAnalysis() {
enabled: !!activeConfigId,
})
// 内置概念预设 (ext_gn_ths) 手动获取数据
const PRESET_CONCEPT_ID = 'ext_gn_ths'
const queryClient = useQueryClient()
const fetchMutation = useMutation({
mutationFn: () => api.extDataPresetFetch(PRESET_CONCEPT_ID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QK.extData })
queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_CONCEPT_ID, undefined, PAGE_LIMIT) })
},
})
// 是否处于「内置概念预设存在但无数据」状态 → 显示获取按钮
const needsConceptFetch =
!!activeConfig && activeConfig.id === PRESET_CONCEPT_ID &&
!rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
@@ -310,18 +327,30 @@ export function ConceptAnalysis() {
}
if (!activeConfig) {
// 极端情况: 无任何概念配置。仍提供一键获取内置概念数据入口
return (
<div className="flex h-full flex-col">
<PageHeader
title="概念分析"
right={
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
}
/>
<EmptyState icon={Database} title="暂无概念数据" hint={'请先在"数据"页面创建包含概念/题材字段的扩展数据源'} />
</div>
<>
<div className="flex h-full flex-col">
<PageHeader
title="概念分析"
right={
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
}
/>
<PresetFetchState
title="暂无概念数据"
hint="从同花顺获取概念分类数据后即可使用概念分析"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
</AnimatePresence>
</>
)
}
@@ -332,6 +361,14 @@ export function ConceptAnalysis() {
subtitle={`${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
{/* RPS 轮动: 打开涨幅轮动矩阵对话框 */}
<button
onClick={() => setShowRps(true)}
className="inline-flex items-center gap-1 rounded-btn border border-amber-400/40 bg-amber-400/15 px-2.5 py-1.5 text-[11px] text-amber-400 font-medium transition-colors hover:bg-amber-400/25 hover:border-amber-400/60"
title="概念涨幅轮动矩阵"
>
<Repeat className="h-3.5 w-3.5" />RPS轮动分析
</button>
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
@@ -374,6 +411,14 @@ export function ConceptAnalysis() {
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div>
) : needsConceptFetch ? (
<PresetFetchState
title="未获取概念数据"
hint="内置概念数据源已就绪,点击下方按钮从同花顺获取概念分类数据"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
) : (
<EmptyState icon={Layers3} title="未匹配到概念数据" hint={resolved.hint || '请检查扩展数据是否包含概念/题材相关字段'} />
)}
@@ -391,6 +436,10 @@ export function ConceptAnalysis() {
onClose={() => { setPreviewSymbol(null); setPreviewName('') }}
/>
)}
<AnimatePresence>
{showRps && <RpsRotationDialog onClose={() => setShowRps(false)} />}
</AnimatePresence>
</>
)
}
+243 -61
View File
@@ -1,9 +1,8 @@
import { useState, type ReactNode } from 'react'
import { useState, useEffect, useRef, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useTheme } from '@/components/ThemeProvider'
import { Activity, AlertTriangle, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Flame, Gauge, LineChart, Loader2, RefreshCw, Sparkles, Target, Timer, ExternalLink } from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, Info, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -11,6 +10,8 @@ import { fmtBigNum, fmtPct } from '@/lib/format'
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { SettingsModal } from '@/components/data/SettingsModal'
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals'
import { boardTag } from '@/components/stock-table/primitives'
@@ -288,21 +289,11 @@ function DistributionBars({ rows }: { rows: OverviewMarket['distribution'] }) {
}
function EmotionRadar({ radar, score }: { radar: OverviewMarket['radar']; score: number }) {
const { resolved } = useTheme()
const isDark = resolved === 'dark'
const size = 240
const cx = size / 2
const cy = size / 2
const maxR = 78
const color = scoreColor(score)
const centerFillStart = isDark ? 'rgba(15,23,42,0.92)' : 'rgba(255,255,255,0.92)'
const centerFillMid = isDark ? 'rgba(15,23,42,0.70)' : 'rgba(255,255,255,0.70)'
const gridFillEven = isDark ? 'rgba(30,41,59,0.26)' : 'rgba(228,228,231,0.35)'
const gridFillOdd = isDark ? 'rgba(15,23,42,0.16)' : 'rgba(244,244,245,0.45)'
const gridStrokeStrong = isDark ? 'rgba(148,163,184,0.22)' : 'rgba(0,0,0,0.12)'
const gridStrokeWeak = isDark ? 'rgba(148,163,184,0.12)' : 'rgba(0,0,0,0.06)'
const spokeStroke = isDark ? 'rgba(148,163,184,0.08)' : 'rgba(0,0,0,0.05)'
const pointStroke = isDark ? 'rgba(15,23,42,0.9)' : 'rgba(255,255,255,0.9)'
if (!radar.length) return <div className="flex h-52 items-center justify-center text-xs text-muted"></div>
const points = radar.map((r, i) => {
const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length
@@ -335,23 +326,23 @@ function EmotionRadar({ radar, score }: { radar: OverviewMarket['radar']; score:
<stop offset="100%" stopColor={`${color}1f`} />
</radialGradient>
<radialGradient id="emotionRadarCenter" cx="50%" cy="50%" r="55%">
<stop offset="0%" stopColor={centerFillStart} />
<stop offset="68%" stopColor={centerFillMid} />
<stop offset="100%" stopColor="rgba(0,0,0,0)" />
<stop offset="0%" stopColor="rgba(15,23,42,0.92)" />
<stop offset="68%" stopColor="rgba(15,23,42,0.70)" />
<stop offset="100%" stopColor="rgba(15,23,42,0)" />
</radialGradient>
</defs>
{gridPolygons.map(g => (
<polygon
key={g.level}
points={g.points}
fill={g.idx % 2 === 0 ? gridFillEven : gridFillOdd}
stroke={g.level === 1 ? gridStrokeStrong : gridStrokeWeak}
fill={g.idx % 2 === 0 ? 'rgba(30,41,59,0.26)' : 'rgba(15,23,42,0.16)'}
stroke={g.level === 1 ? 'rgba(148,163,184,0.22)' : 'rgba(148,163,184,0.12)'}
strokeWidth={g.level === 1 ? 1.2 : 0.8}
/>
))}
{points.map(p => <line key={p.key} x1={cx} y1={cy} x2={p.gx} y2={p.gy} stroke={spokeStroke} />)}
{points.map(p => <line key={p.key} x1={cx} y1={cy} x2={p.gx} y2={p.gy} stroke="rgba(148,163,184,0.08)" />)}
<polygon points={polygon} fill="url(#emotionRadarFill)" stroke={color} strokeWidth="2" />
{points.map(p => <circle key={p.key} cx={p.x} cy={p.y} r="2.8" fill={color} stroke={pointStroke} strokeWidth="1" />)}
{points.map(p => <circle key={p.key} cx={p.x} cy={p.y} r="2.8" fill={color} stroke="rgba(15,23,42,0.9)" strokeWidth="1" />)}
<circle cx={cx} cy={cy} r="29" fill="url(#emotionRadarCenter)" />
<text x={cx} y={cy + 7} textAnchor="middle" className="fill-foreground font-mono text-[24px] font-bold">{score}</text>
{points.map(p => (
@@ -480,8 +471,11 @@ function HotRankCard({ title, rank, configUrl }: { title: string; rank?: Overvie
}
export function Dashboard() {
const qc = useQueryClient()
const [selectedDate, setSelectedDate] = useState<string | undefined>()
const [manualFetching, setManualFetching] = useState(false)
// 首次使用(无数据 + 未完成引导)自动弹窗: 同一会话只弹一次
const [showWelcomeModal, setShowWelcomeModal] = useState(false)
const dataStatus = useDataStatus({ staleTime: 60_000 })
const overview = useQuery({
queryKey: QK.overviewMarket(selectedDate),
@@ -495,15 +489,67 @@ export function Dashboard() {
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const sealedReady = !!data?.limit?.sealed_ready
const isSealedDegrade = !hasDepth || !sealedReady
// none 档(无 key / 无效 key)→ 显示升级提示横幅
// none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限
const isNoKey = settings.data?.mode === 'none'
// 无本地数据(enriched/daily 都没有)→ 提示去数据页同步
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
// 注: 后端 status 的 rows 为性能刻意返回 0, 用 trading_days 判断是否有数据
const ds = dataStatus.data
const hasNoData = !!ds
&& (ds.enriched?.trading_days ?? 0) === 0
&& (ds.daily?.trading_days ?? 0) === 0
// ===== 盘后管道触发(看板内一键获取数据) =====
const [fetchJobId, setFetchJobId] = useState<string | null>(null)
const fetchStatus = useQuery({
queryKey: QK.pipelineJob(fetchJobId ?? ''),
queryFn: () => api.pipelineJob(fetchJobId!),
enabled: !!fetchJobId,
refetchInterval: (q: any) => {
const j = q.state.data
return j && (j.status === 'succeeded' || j.status === 'failed') ? false : 1_000
},
})
const startFetch = useMutation({
mutationFn: api.pipelineRun,
onSuccess: ({ job_id }) => setFetchJobId(job_id),
})
const isFetching = startFetch.isPending
|| fetchStatus.data?.status === 'running'
|| fetchStatus.data?.status === 'pending'
const fetchFailed = fetchStatus.data?.status === 'failed'
const fetchSucceeded = fetchStatus.data?.status === 'succeeded'
// 首次使用且无数据 → 自动弹一次引导弹窗(同会话只弹一次)
useEffect(() => {
if (!hasNoData) return
if (settings.data?.onboarding_completed === false) return // 还在引导流程中,不重复弹
if (sessionStorage.getItem('tf_welcome_shown')) return
sessionStorage.setItem('tf_welcome_shown', '1')
setShowWelcomeModal(true)
}, [hasNoData, settings.data?.onboarding_completed])
// 同步完成后刷新看板数据
useEffect(() => {
if (fetchSucceeded) {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.overviewMarket(undefined) })
}
}, [fetchSucceeded, qc])
// 组件重新挂载时(从其他页面切回)恢复正在运行的同步任务进度。
// 原因: fetchJobId 是组件内状态, 切走页面时组件卸载、状态丢失, 切回后进度卡片消失。
// 修复: 挂载时若无本地数据且未跟踪任何 job, 查一次后端是否有 active job, 有则接管。
const resumeTriedRef = useRef(false)
useEffect(() => {
if (resumeTriedRef.current) return
if (!hasNoData) return
if (fetchJobId) return
resumeTriedRef.current = true
api.pipelineJobs(1).then(({ active_id }) => {
if (active_id) setFetchJobId(active_id)
}).catch(() => { /* 查询失败不阻塞, 用户仍可手动点击获取 */ })
}, [hasNoData, fetchJobId])
// 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感
const handleRefresh = () => {
setManualFetching(true)
@@ -537,44 +583,37 @@ export function Dashboard() {
const latestDate = dataStatus.data?.enriched?.latest_date ?? null
const currentDate = selectedDate ?? data.as_of ?? ''
const quoteRunning = (!selectedDate || selectedDate === latestDate) && data.quote_status?.running
// 实时模式: none / watchlist / full_market。
// watchlist (Free 档) 仅自选 ≤5 只实时, 看板呈现的大盘数据实为盘后快照, 需提示避免误读。
const quoteMode = data.quote_status?.mode as ('none' | 'watchlist' | 'full_market') | undefined
return (
<div className="min-h-full bg-base p-3">
{/* none 档(无 key)提示横幅 —— 引导用户领取免费 Key 解锁完整能力 */}
{isNoKey && (
<div className="mb-3 flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-secondary leading-relaxed">
API Key,
<a
href="https://tickflow.org/auth/register"
target="_blank"
rel="noreferrer"
className="mx-1 inline-flex items-baseline gap-0.5 font-medium text-warning hover:underline"
>
<ExternalLink className="h-3 w-3 self-center" />
</a>
API Key,
</span>
</div>
)}
{/* 无本地数据提示 —— 引导用户去数据页同步 (仅当已配置 Key 时显示, 无 Key 时优先提示配置 Key) */}
{hasNoData && !isNoKey && (
<div className="mb-3 flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-secondary leading-relaxed">
,
<Link
to="/data"
className="ml-1 shrink-0 inline-flex items-center gap-0.5 font-medium text-warning hover:underline"
>
<ArrowUpRight className="h-3 w-3 self-center" />
</Link>
</span>
</div>
{/* 无本地数据常驻引导卡片 —— 一键触发盘后管道获取数据(无 Key 也可) */}
{hasNoData && (
<FetchDataCard
isFetching={isFetching}
isStarting={startFetch.isPending}
fetchFailed={fetchFailed}
stage={fetchStatus.data?.stage}
fetchPct={fetchStatus.data?.progress}
onStart={() => startFetch.mutate()}
isNoKey={isNoKey}
/>
)}
{/* 首次使用自动弹窗(同会话仅一次) */}
<AnimatePresence>
{showWelcomeModal && (
<WelcomeFetchModal
isNoKey={isNoKey}
onClose={() => setShowWelcomeModal(false)}
onStart={() => {
startFetch.mutate()
setShowWelcomeModal(false)
}}
/>
)}
</AnimatePresence>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-card border border-border bg-surface/85 px-3 py-2">
<div className="flex items-center gap-2">
<Gauge className="h-4 w-4 text-accent" />
@@ -614,6 +653,18 @@ export function Dashboard() {
</div>
</div>
{/* Free 档提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */}
{quoteMode === 'watchlist' && (
<div className="mb-3 flex items-start gap-2 rounded-card border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-[11px] leading-relaxed">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="min-w-0 flex-1 text-secondary">
,<strong className="text-foreground"></strong>(),;
({data.quote_status?.watchlist_symbol_count ?? 0} )
<span className="ml-1 text-accent"> Starter+</span>
</div>
</div>
)}
<div className="mb-3 grid grid-cols-4 gap-2">
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
</div>
@@ -624,7 +675,7 @@ export function Dashboard() {
<KpiCell label={<span className="inline-flex items-center gap-1"> / <SealedBadge degraded={isSealedDegrade} hasDepth={hasDepth} isHistorical={false} sealedReady={sealedReady} sealedCountsUp={{ real: data.limit.limit_up, fake: data.limit.fake_up ?? 0, pending: 0 }} sealedCountsDown={{ real: data.limit.limit_down, fake: data.limit.fake_down ?? 0, pending: 0 }} rawUp={data.limit.limit_up + (data.limit.fake_up ?? 0)} rawDown={data.limit.limit_down + (data.limit.fake_down ?? 0)} invalidateKeys={['overview-market', 'limit-ladder']} /></span>} value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} />
<KpiCell label="最高连板" value={`${data.limit.max_boards || 0}`} sub={`梯队 ${data.limit.tiers.length}`} tone="accent" />
<KpiCell label="成交额" value={fmtBigNum(data.amount.total)} sub={`均额 ${fmtBigNum(data.amount.avg)}`} />
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover} · 放量 ${data.activity.high_vol_ratio}`} tone="accent" />
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover} · 放量占比 ${fmtPrice(data.activity.high_vol_ratio, 1)}%`} tone="accent" />
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-[minmax(0,1fr)_20rem]">
@@ -670,7 +721,7 @@ export function Dashboard() {
<MiniMetric label="站上MA60" value={`${data.trend.above_ma60_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="新高/新低" value={`${compactCount(data.trend.new_high)}/${compactCount(data.trend.new_low)}`} cls={data.trend.new_high >= data.trend.new_low ? 'text-bull' : 'text-bear'} />
<MiniMetric label="高换手数" value={`${data.activity.high_turnover}`} cls="text-accent" />
<MiniMetric label="放量家数" value={`${data.activity.high_vol_ratio}`} cls="text-accent" />
<MiniMetric label="放量占比" value={`${fmtPrice(data.activity.high_vol_ratio, 1)}%`} cls="text-accent" />
</div>
</div>
</section>
@@ -691,7 +742,7 @@ export function Dashboard() {
<aside className="min-w-0 space-y-3">
<section className="rounded-card border border-border bg-surface/80 p-3">
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}{isSealedDegrade && <span className="text-[9px] px-1 rounded bg-yellow-500/10 text-yellow-600">{hasDepth ? '未修正' : '降级'}</span>}</span>} />
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}{isSealedDegrade && <span className="text-[9px] px-1 rounded bg-yellow-500/10 text-yellow-600 dark:text-yellow-500">{hasDepth ? '未修正' : '降级'}</span>}</span>} />
<LadderMini limit={data.limit} />
</section>
<section className="rounded-card border border-border bg-surface/80 p-3">
@@ -712,3 +763,134 @@ export function Dashboard() {
</div>
)
}
// ===== 无数据常驻引导卡片: 一键触发盘后管道获取行情数据(无 Key 也可) =====
function FetchDataCard({
isFetching, isStarting, fetchFailed, stage, fetchPct, onStart, isNoKey,
}: {
isFetching: boolean
isStarting: boolean
fetchFailed: boolean
stage?: string
fetchPct?: number
onStart: () => void
isNoKey: boolean
}) {
const stageText = stage ? (STAGE_LABELS[stage] ?? stage) : '正在同步行情数据…'
return (
<div className="mb-3 rounded-card border border-border bg-surface/85 p-3.5">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-accent/10 p-2 shrink-0">
<Database className="h-4 w-4 text-accent" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground"></div>
<p className="mt-1 text-xs text-secondary leading-relaxed">
使 1 A K( 5500 ), 1-3 ,
</p>
{isNoKey && (
<p className="mt-1 text-[11px] text-warning/80 leading-relaxed">
API Key, None K,+ Key
</p>
)}
{isFetching ? (
<div className="mt-3">
<div className="flex items-center justify-between text-[11px] text-muted mb-1.5">
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
{isStarting ? '正在启动同步任务…' : stageText}
</span>
<span className="font-mono tabular">
{typeof fetchPct === 'number' ? `${Math.round(fetchPct)}%` : ''}
</span>
</div>
<div className="h-1.5 rounded-full bg-elevated overflow-hidden">
<motion.div
className="h-full bg-accent"
initial={{ width: 0 }}
animate={{ width: `${Math.max(2, Math.min(100, fetchPct ?? 0))}%` }}
transition={{ duration: 0.4, ease: 'easeOut' }}
/>
</div>
</div>
) : fetchFailed ? (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-danger">,</span>
<button
onClick={onStart}
className="inline-flex items-center gap-1.5 px-3 h-8 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 transition-colors"
>
<Play className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="mt-3 flex items-center gap-3">
<button
onClick={onStart}
className="inline-flex items-center gap-1.5 px-4 h-8 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 transition-colors"
>
<Play className="h-3.5 w-3.5" />
</button>
<Link
to="/data"
className="inline-flex items-center gap-0.5 text-xs text-secondary hover:text-accent transition-colors"
>
<ArrowUpRight className="h-3 w-3 self-center" />
</Link>
</div>
)}
</div>
</div>
</div>
)
}
// ===== 首次使用自动弹窗: 询问用户后触发盘后管道 =====
function WelcomeFetchModal({
isNoKey, onClose, onStart,
}: {
isNoKey: boolean
onClose: () => void
onStart: () => void
}) {
return (
<SettingsModal title="欢迎首次使用 · 获取行情数据" onClose={onClose}>
<div className="text-center">
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="mx-auto w-fit rounded-2xl bg-accent/10 p-3.5"
>
<Sparkles className="h-7 w-7 text-accent" />
</motion.div>
<h3 className="mt-4 text-base font-semibold text-foreground">使,</h3>
<p className="mt-2 text-xs text-secondary leading-relaxed">
1 A K( 5500 ), 1-3
,
</p>
{isNoKey && (
<div className="mt-3 rounded-btn bg-elevated/60 px-3 py-2 text-[11px] text-muted leading-relaxed">
API Key,None K数据
</div>
)}
<div className="mt-5 flex items-center justify-center gap-2.5">
<button
onClick={onClose}
className="px-4 h-9 rounded-btn text-sm text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
</button>
<button
onClick={onStart}
className="inline-flex items-center gap-2 px-5 h-9 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 transition-all"
>
<Play className="h-4 w-4" />
</button>
</div>
</div>
</SettingsModal>
)
}
+260 -158
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import {
@@ -9,11 +8,16 @@ import {
HardDrive,
Clock,
Calendar,
CheckSquare,
Trash2,
Plus,
Wifi,
SlidersHorizontal,
AlertTriangle,
Info,
} from 'lucide-react'
import { Link } from 'react-router-dom'
import { EndpointTestDialog } from '@/components/EndpointTestDialog'
import { api, type ExtDataConfig } from '@/lib/api'
import {
useCapabilities,
@@ -37,6 +41,8 @@ import { ScheduleEditor } from '@/components/data/ScheduleEditor'
import { ExtendHistoryPanel } from '@/components/data/ExtendHistoryPanel'
import { EnrichedRebuildPanel } from '@/components/data/EnrichedRebuildPanel'
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
import { PipelineScopeConfig } from '@/components/data/PipelineScopeConfig'
import { PageSettingsModal, getCardVisibility, getCardOrder, type CardKey } from '@/components/data/PageSettingsModal'
import { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
import { Skeleton } from '@/components/data/Skeleton'
@@ -116,8 +122,7 @@ export function Data() {
const [indexExtendValue, setIndexExtendValue] = useState(6)
const [indexExtendUnit, setIndexExtendUnit] = useState<'month' | 'year'>('month')
const [schemaTable, setSchemaTable] = useState<string | null>(null)
// 测试端点入口已隐藏,后续需要时恢复下方状态与相关 JSX
// const [showEndpointTest, setShowEndpointTest] = useState(false)
const [showEndpointTest, setShowEndpointTest] = useState(false)
const [showCreateExt, setShowCreateExt] = useState(false)
const [editingExt, setEditingExt] = useState<ExtDataConfig | null>(null)
const [indexBatchInput, setIndexBatchInput] = useState('100')
@@ -186,7 +191,27 @@ export function Data() {
const hasAdjCap = !!caps.data?.capabilities?.['adj_factor']
const hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch']
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
const pipelineSteps = ['日K', ...(hasAdjCap ? ['复权'] : []), '指标', '指数', ...((hasMinuteCap && minuteAuto) ? ['分钟K'] : [])]
const indexAuto = prefs.data?.pipeline_pull_index ?? true
const etfAuto = prefs.data?.pipeline_pull_etf ?? false
const pipelineSteps = [
'日K',
...(hasAdjCap ? ['复权'] : []),
'指标',
...(indexAuto ? ['指数'] : []),
...(etfAuto ? ['ETF'] : []),
...((hasMinuteCap && minuteAuto) ? ['分钟K'] : []),
]
// 数据画像卡片显隐(由页面设置弹窗控制,存 localStorage)
const [cardVisibleTick, setCardVisibleTick] = useState(0)
useEffect(() => {
const handler = () => setCardVisibleTick(t => t + 1)
window.addEventListener('data-card-visible-change', handler)
return () => window.removeEventListener('data-card-visible-change', handler)
}, [])
const cardVisible = getCardVisibility(caps.data?.capabilities)
// 引用 cardVisibleTick 触发重渲染(避免 lint 警告)
void cardVisibleTick
useEffect(() => {
if (job.data && (job.data.status === 'succeeded' || job.data.status === 'failed')) {
@@ -223,6 +248,14 @@ export function Data() {
symbols_covered: s.index_daily?.symbols_covered ?? s.index_instruments?.rows ?? 0,
trading_days: s.index_daily?.trading_days ?? s.index_enriched?.trading_days ?? 0,
} : null
// ETF 统计(后端已按 asset_type='etf' 从 index 存储中拆分)
const etfOverviewStats = s ? {
rows: 0,
earliest_date: s.etf_daily?.earliest_date ?? s.etf_enriched?.earliest_date ?? null,
latest_date: s.etf_daily?.latest_date ?? s.etf_enriched?.latest_date ?? null,
symbols_covered: s.etf_daily?.symbols_covered ?? s.etf_instruments?.rows ?? 0,
trading_days: s.etf_daily?.trading_days ?? s.etf_enriched?.trading_days ?? 0,
} : null
const indexOverviewLabel = s ? '日 · 维表 · 日K · 指标' : undefined
const indexEarliestDate = s?.index_daily?.earliest_date ?? s?.index_enriched?.earliest_date ?? null
const indexOffsetDays = indexExtendUnit === 'month' ? indexExtendValue * 30 : indexExtendValue * 365
@@ -284,6 +317,171 @@ export function Data() {
})
}, [])
// 按卡片 key 渲染对应的 StatCard (顺序由 getCardOrder 控制, 显隐由 cardVisible 控制)
const renderStatCard = (k: CardKey): React.ReactNode => {
switch (k) {
case 'instruments':
return (
<StatCard
title="个股维表"
hint="盘前同步 · 元数据快照"
stats={s?.instruments}
isInstrument
loading={isLoading}
active={activeCard === 'instruments'}
done={doneStages.has('instruments')}
skipped={skippedCards.has('instruments')}
stagePct={activeCard === 'instruments' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="instruments"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('instruments')}
/>
)
case 'daily':
return (
<StatCard
title="日 K"
hint="增量同步 · 全市场"
stats={s?.daily}
loading={isLoading}
active={activeCard === 'daily'}
done={doneStages.has('daily')}
skipped={skippedCards.has('daily')}
stagePct={activeCard === 'daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined}
settingsOpen={openSettings === 'daily'}
/>
)
case 'adj_factor':
return (
<StatCard
title="除权因子"
hint="增量同步 · 全市场"
stats={s?.adj_factor}
loading={isLoading}
active={activeCard === 'adj_factor'}
done={doneStages.has('adj_factor')}
skipped={skippedCards.has('adj_factor')}
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="adj_factor"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('adj_factor')}
/>
)
case 'enriched':
return (
<StatCard
title="Enriched"
hint="复权 OHLCV + 技术指标"
stats={s?.enriched}
loading={isLoading}
active={activeCard === 'enriched'}
done={doneStages.has('enriched')}
skipped={skippedCards.has('enriched')}
stagePct={activeCard === 'enriched' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="enriched"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
subLabel="字段 · 指标 · 信号"
localBadgeSuffix={`${prefs.data?.enriched_batch_size ?? 1000}只/批`}
onShowFields={() => setSchemaTable('enriched')}
onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined}
settingsOpen={openSettings === 'enriched'}
/>
)
case 'index':
return (
<StatCard
title="指数"
hint="CN_Index · 独立存储"
stats={indexOverviewStats}
loading={isLoading}
active={activeCard === 'index_daily'}
done={doneStages.has('index_daily')}
skipped={skippedCards.has('index_daily')}
stagePct={activeCard === 'index_daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={indexAuto}
subLabel={indexOverviewLabel}
fieldTabs={[
{ label: '维表', table: 'index_instruments' },
{ label: '日K', table: 'index_daily' },
{ label: '指标', table: 'index_enriched' },
] as FieldTab[]}
onShowFields={(t) => setSchemaTable(t ?? 'index_daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined}
settingsOpen={openSettings === 'index'}
/>
)
case 'etf':
return (
<StatCard
title="ETF"
hint="场内基金 · 独立存储"
stats={etfOverviewStats}
loading={isLoading}
tierKey="etf"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={etfAuto}
subLabel="维表 · 日K · 指标"
fieldTabs={[
{ label: '维表', table: 'etf_instruments' },
{ label: '日K', table: 'etf_daily' },
{ label: '指标', table: 'etf_enriched' },
] as FieldTab[]}
onShowFields={(t) => setSchemaTable(t ?? 'etf_daily')}
/>
)
case 'minute':
return (
<StatCard
title="分钟 K"
hint="全市场同步"
stats={s?.minute}
loading={isLoading}
active={activeCard === 'minute'}
done={doneStages.has('minute')}
skipped={skippedCards.has('minute')}
stagePct={activeCard === 'minute' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="minute"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={minuteAuto}
onShowFields={() => setSchemaTable('minute')}
onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined}
settingsOpen={openSettings === 'minute'}
/>
)
case 'financials':
return (
<StatCard
title="财务数据"
hint="利润表 / 资负表 / 现金流 / 指标"
stats={s?.financials ? { rows: s.financials.rows } : null}
loading={isLoading}
tierKey="financials"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
/>
)
default:
return null
}
}
return (
<>
<div ref={topRef} />
@@ -292,31 +490,28 @@ export function Data() {
subtitle="本地数据画像 · 同步状态 · 历史记录"
right={
<div className="flex items-center gap-3">
{!hasData && !isLoading && !isNoKey && (
{!hasData && !isLoading && (
<span className="text-xs text-accent animate-pulse">使</span>
)}
{isNoKey ? (
<button
disabled
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium opacity-40 cursor-not-allowed transition-all duration-150"
>
<button
onClick={() => startSync.mutate()}
disabled={isStarting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 disabled:opacity-40 transition-all duration-150"
>
{(isRunning || isStarting) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => startSync.mutate()}
disabled={isStarting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 disabled:opacity-40 transition-all duration-150"
>
{(isRunning || isStarting) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
{isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'}
</button>
)}
)}
{isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'}
</button>
<button
onClick={() => setOpenSettings('pipeline-scope')}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
>
<CheckSquare className="h-3.5 w-3.5" />
</button>
<div className="w-px h-4 bg-border" />
<div className="flex items-center gap-1.5">
<button
@@ -326,7 +521,6 @@ export function Data() {
<Plus className="h-3.5 w-3.5" />
</button>
{/* 测试端点入口已隐藏
<button
onClick={() => setShowEndpointTest(true)}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
@@ -334,7 +528,13 @@ export function Data() {
<Wifi className="h-3.5 w-3.5" />
</button>
*/}
<button
onClick={() => setOpenSettings('page-settings')}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
>
<SlidersHorizontal className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setShowClearConfirm(true)}
disabled={isRunning}
@@ -349,13 +549,14 @@ export function Data() {
/>
<div className="px-8 py-6 space-y-6 max-w-6xl">
{/* 未配置 API Key 告警条 —— 引导用户去配置 Key 后才能同步 */}
{/* None 档提示 —— 非阻断: 无需 Key 也可获取历史日K, 仅实时行情等扩展能力受限 */}
{isNoKey && (
<div className="flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<div className="flex items-center gap-2 rounded-card border border-border bg-elevated/40 px-3 py-2 text-xs">
<Info className="h-4 w-4 shrink-0 text-muted" />
<span className="text-secondary leading-relaxed">
API Key,,
<Link to="/settings?tab=account" className="mx-0.5 font-medium text-warning hover:underline">
None ,使K()
API Key ,
<Link to="/settings?tab=account" className="mx-0.5 font-medium text-accent hover:underline">
</Link>
@@ -412,7 +613,7 @@ export function Data() {
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted pb-2 border-b border-border/50">
<span className="text-accent/60 font-medium"></span>
<span></span>
<span></span>
<span className="text-border"></span>
<span className="text-accent/60 font-medium"></span>
{pipelineSteps.map((step, i) => (
@@ -428,7 +629,7 @@ export function Data() {
</div>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted"> · </span>
<span className="text-muted"> · </span>
<span className="text-muted/50">·</span>
<span className="font-mono text-secondary">
{`${String(instrumentsSched.hour).padStart(2, '0')}:${String(instrumentsSched.minute).padStart(2, '0')}`}
@@ -549,7 +750,7 @@ export function Data() {
</div>
))
) : [
{ label: '标的维表', files: s?.storage.instruments_files, size: s?.storage.instruments_size_mb },
{ label: '个股维表', files: s?.storage.instruments_files, size: s?.storage.instruments_size_mb },
{ label: '日 K', files: s?.storage.daily_files, size: s?.storage.daily_size_mb },
{ label: '除权因子', files: s?.storage.adj_factor_files, size: s?.storage.adj_factor_size_mb },
{ label: 'Enriched', files: s?.storage.enriched_files, size: s?.storage.enriched_size_mb },
@@ -584,122 +785,9 @@ export function Data() {
<div>
<SectionTitle icon={Database}></SectionTitle>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 items-stretch">
<StatCard
title="标的维表"
hint="盘前同步 · 元数据快照"
stats={s?.instruments}
isInstrument
loading={isLoading}
active={activeCard === 'instruments'}
done={doneStages.has('instruments')}
skipped={skippedCards.has('instruments')}
stagePct={activeCard === 'instruments' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="instruments"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('instruments')}
/>
<StatCard
title="日 K"
hint="增量同步 · 全市场"
stats={s?.daily}
loading={isLoading}
active={activeCard === 'daily'}
done={doneStages.has('daily')}
skipped={skippedCards.has('daily')}
stagePct={activeCard === 'daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined}
settingsOpen={openSettings === 'daily'}
/>
<StatCard
title="除权因子"
hint="增量同步 · 全市场"
stats={s?.adj_factor}
loading={isLoading}
active={activeCard === 'adj_factor'}
done={doneStages.has('adj_factor')}
skipped={skippedCards.has('adj_factor')}
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="adj_factor"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('adj_factor')}
/>
<StatCard
title="Enriched"
hint="复权 OHLCV + 技术指标"
stats={s?.enriched}
loading={isLoading}
active={activeCard === 'enriched'}
done={doneStages.has('enriched')}
skipped={skippedCards.has('enriched')}
stagePct={activeCard === 'enriched' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="enriched"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
subLabel="字段 · 指标 · 信号"
localBadgeSuffix={`${prefs.data?.enriched_batch_size ?? 1000}只/批`}
onShowFields={() => setSchemaTable('enriched')}
onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined}
settingsOpen={openSettings === 'enriched'}
/>
<StatCard
title="指数数据"
hint="CN_Index · 独立存储"
stats={indexOverviewStats}
loading={isLoading}
active={activeCard === 'index_daily'}
done={doneStages.has('index_daily')}
skipped={skippedCards.has('index_daily')}
stagePct={activeCard === 'index_daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
subLabel={indexOverviewLabel}
fieldTabs={[
{ label: '维表', table: 'index_instruments' },
{ label: '日K', table: 'index_daily' },
{ label: '指标', table: 'index_enriched' },
] as FieldTab[]}
onShowFields={(t) => setSchemaTable(t ?? 'index_daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined}
settingsOpen={openSettings === 'index'}
/>
<StatCard
title="分钟 K"
hint="全市场同步"
stats={s?.minute}
loading={isLoading}
active={activeCard === 'minute'}
done={doneStages.has('minute')}
skipped={skippedCards.has('minute')}
stagePct={activeCard === 'minute' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="minute"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={minuteAuto}
onShowFields={() => setSchemaTable('minute')}
onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined}
settingsOpen={openSettings === 'minute'}
/>
<StatCard
title="财务数据"
hint="利润表 / 资负表 / 现金流 / 指标"
stats={s?.financials ? { rows: s.financials.rows } : null}
loading={isLoading}
tierKey="financials"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
/>
{getCardOrder().filter(k => cardVisible[k]).map((k: CardKey) => (
<Fragment key={k}>{renderStatCard(k)}</Fragment>
))}
{(extConfigs.data?.items ?? []).map((ext) => (
<ExtDataStatCard
key={ext.id}
@@ -758,7 +846,6 @@ export function Data() {
onClose={() => setSchemaTable(null)}
/>
{/* 测试端点弹窗已隐藏
{showEndpointTest && (
<EndpointTestDialog
hasKey={settings.data?.mode === 'api_key'}
@@ -767,7 +854,6 @@ export function Data() {
onClose={() => setShowEndpointTest(false)}
/>
)}
*/}
<AnimatePresence>
{showCreateExt && (
@@ -799,9 +885,25 @@ export function Data() {
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'pipeline-scope' && (
<SettingsModal title="盘后管道 · 拉取内容" onClose={() => setOpenSettings(null)}>
<PipelineScopeConfig />
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'page-settings' && (
<SettingsModal title="页面设置 · 数据画像卡片" onClose={() => setOpenSettings(null)}>
<PageSettingsModal caps={caps.data?.capabilities} />
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'index' && (
<SettingsModal title="指数数据 · 手动获取" onClose={() => setOpenSettings(null)}>
<SettingsModal title="指数 · 手动获取" onClose={() => setOpenSettings(null)}>
<div className="space-y-4">
<div className="rounded-card border border-border bg-base/30 p-4 space-y-3">
<div>
@@ -939,9 +1041,9 @@ export function Data() {
<span className="text-danger font-medium"></span>
</p>
<ul className="mt-2 text-[11px] text-muted leading-relaxed space-y-0.5">
<li>· K</li>
<li>· K</li>
<li>· Enriched K</li>
<li>· </li>
<li>· ETF</li>
</ul>
<p className="mt-2 text-[11px] text-danger/90">
+144 -7
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, Search, AlertTriangle, CheckCircle2, XCircle, FlaskConical, Activity } from 'lucide-react'
import { Loader2, Search, AlertTriangle, CheckCircle2, XCircle, FlaskConical, Activity, Bell } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
@@ -66,7 +66,7 @@ function MinuteProbePanel() {
<h2 className="text-sm font-semibold text-foreground">K数据探测</h2>
<p className="mt-1 text-xs text-muted">
<code className="px-1 rounded bg-elevated text-secondary">/api/kline/minute</code>
K数据是否齐全
K数据是否齐全 TickFlow
</p>
</div>
@@ -172,7 +172,7 @@ function MinuteProbePanel() {
<span className="text-foreground">/</span>
<span className="text-foreground"></span> 0
<span className="text-foreground"></span>K有成交量K
TickFlow
</p>
</div>
)}
@@ -315,15 +315,143 @@ function SeedPanel() {
)
}
// ── 封单监控模拟触发 ──────────────────────────────────
function LadderTestPanel() {
const [result, setResult] = useState<Awaited<ReturnType<typeof api.monitorRuleTestLadder>> | null>(null)
const [error, setError] = useState('')
const [pushMsg, setPushMsg] = useState('')
const testMut = useMutation({
mutationFn: () => api.monitorRuleTestLadder(),
onSuccess: (data) => { setResult(data); setError('') },
onError: (e: any) => { setError(e?.message ?? String(e)); setResult(null) },
})
const triggerMut = useMutation({
mutationFn: () => api.monitorRuleTriggerLadder(),
onSuccess: (data) => {
setPushMsg(`✅ 已真实触发 ${data.triggered} 条预警 (落盘 + 飞书 + SSE)`)
setTimeout(() => setPushMsg(''), 6000)
},
onError: (e: any) => {
setPushMsg(`❌ 触发失败: ${e?.message ?? String(e)}`)
setTimeout(() => setPushMsg(''), 6000)
},
})
const fmtVal = (v: number | null | undefined, metric: string) => {
if (v == null) return '—'
if (metric === 'sealed_amount') return `${(v / 1e8).toFixed(4)} 亿`
return `${v.toLocaleString()}`
}
return (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold text-foreground"></h2>
<p className="mt-1 text-xs text-muted">
depth + enriched, ladder
</p>
</div>
<div className="flex flex-wrap items-center gap-3 rounded-btn bg-elevated p-4">
<button
onClick={() => testMut.mutate()}
disabled={testMut.isPending}
className="flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-sm font-medium text-base hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
>
{testMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
</button>
<button
onClick={() => {
if (confirm('将真实推送飞书 + 写入监控中心 + 触发 SSE 通知。确认?')) {
triggerMut.mutate()
}
}}
disabled={triggerMut.isPending}
className="flex items-center gap-1.5 rounded-btn border border-amber-400/40 bg-amber-400/10 px-4 py-1.5 text-sm font-medium text-amber-400 hover:bg-amber-400/20 disabled:opacity-50 cursor-pointer"
>
{triggerMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
</button>
{result && (
<span className="text-xs text-muted">
{result.as_of} · {result.sealed_count} · {result.triggered.length} · {result.not_triggered.length}
</span>
)}
</div>
<div className="rounded-btn border border-amber-400/30 bg-amber-400/5 p-3 text-xs text-muted">
<span className="font-medium text-amber-400"></span>:,
<span className="font-medium text-amber-400 ml-2"></span>:( alerts.jsonl + + SSE ),
</div>
{pushMsg && (
<div className="rounded-btn border border-accent/40 bg-accent/10 p-3 text-sm text-accent">{pushMsg}</div>
)}
{error && (
<div className="flex items-center gap-2 rounded-btn border border-danger/40 bg-danger/10 p-3 text-sm text-danger">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{result && result.triggered.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-emerald-400"> ({result.triggered.length})</h3>
{result.triggered.map((ev) => (
<div key={ev.rule_id} className="rounded-btn border border-emerald-400/30 bg-emerald-400/5 p-3 text-sm">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground">{ev.symbol}</span>
{ev.name && <span className="text-secondary">{ev.name}</span>}
<span className="ml-auto rounded bg-emerald-400/15 px-1.5 py-0.5 text-xs text-emerald-400">{ev.type}</span>
</div>
<div className="text-xs text-secondary">{ev.message}</div>
<div className="mt-1 text-xs text-muted tabular-nums">
: {fmtVal(ev.sealed_metric === 'sealed_amount' ? ev.current_sealed_amount : ev.current_sealed_vol, ev.sealed_metric)}
</div>
</div>
))}
</div>
)}
{result && result.not_triggered.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted"> ({result.not_triggered.length})</h3>
{result.not_triggered.map((r) => (
<div key={r.rule_id} className="rounded-btn border border-border bg-surface/40 p-3 text-sm">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground">{r.symbol}</span>
<span className="text-secondary text-xs">{r.rule_name}</span>
</div>
<div className="text-xs text-muted tabular-nums">
{fmtVal(r.threshold, r.metric)} · {fmtVal(r.current_value, r.metric)} · {r.reason}
</div>
</div>
))}
</div>
)}
{result && result.triggered.length === 0 && result.not_triggered.length === 0 && (
<div className="rounded-btn border border-border bg-surface/40 p-4 text-center text-sm text-muted">
ladder ,
</div>
)}
</div>
)
}
// ── Dev 主页面 ────────────────────────────────────────
export function Dev() {
const [tab, setTab] = useState<'minute' | 'seed'>('seed')
const [tab, setTab] = useState<'minute' | 'seed' | 'ladder'>('seed')
return (
<div className="flex flex-col h-full">
<PageHeader
title="开发者工具"
subtitle="调试与测试 · 不暴露在菜单"
title="系统工具"
subtitle="数据诊断与预警调试"
right={
<div className="flex items-center gap-1 rounded-btn bg-elevated p-0.5">
<button
@@ -335,6 +463,15 @@ export function Dev() {
>
<FlaskConical className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setTab('ladder')}
className={cn(
'inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer',
tab === 'ladder' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary',
)}
>
<Bell className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setTab('minute')}
className={cn(
@@ -349,7 +486,7 @@ export function Dev() {
/>
<div className="flex-1 overflow-auto px-5 py-4">
<div className="mx-auto max-w-3xl space-y-4">
{tab === 'minute' ? <MinuteProbePanel /> : <SeedPanel />}
{tab === 'minute' ? <MinuteProbePanel /> : tab === 'ladder' ? <LadderTestPanel /> : <SeedPanel />}
</div>
</div>
</div>
+78 -20
View File
@@ -1,12 +1,16 @@
import { useState, useRef } from 'react'
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass } from 'lucide-react'
import { useState, useEffect } from 'react'
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { useCapabilities } from '@/lib/useSharedQueries'
import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
import { LastStockChip } from '@/components/LastStockChip'
import { useLastStock } from '@/lib/useLastStock'
import { fmtBigNum } from '@/lib/format'
import { toast } from '@/components/Toast'
const TABLE_LABELS: Record<string, string> = {
metrics: '核心指标',
@@ -27,20 +31,36 @@ export function Financials() {
const hasFinancial = caps?.capabilities?.['financial'] != null
const { data: status, isLoading } = useFinancialStatus()
const syncMut = useFinancialSync()
// 同步状态: 服务端 syncing 真值优先, 兜底本地 mutation pending
// 同步进行中 = 服务端真值(status.syncing)或本地乐观态(请求已发出待确认)。
// 乐观窗口:点击后到 invalidate 触发的 refetch 返回之间,status.syncing 暂为 false,
// 用 syncMut.isPending 覆盖,让按钮立即置灰、避免重复点击。
// 后端 trigger() 返回时 syncing 已为 true,refetch 到达后 status.syncing 接管。
const syncing = (status?.syncing ?? false) || syncMut.isPending
// 本次同步开始时间戳(ms): 用于判断每张表的 last_sync 是否属于本次同步
// (后端每张表完成即更新 last_sync, 前端轮询时对比时间戳得到精确进度)
const syncStartedAtRef = useRef<number | null>(null)
const [syncStartedAt, setSyncStartedAt] = useState<number | null>(null)
// 单表同步时记录表名 (null = 全量同步), 用于区分卡片状态
const syncSingleTableRef = useRef<string | null>(null)
const [syncSingleTable, setSyncSingleTable] = useState<string | null>(null)
// 同步自然结束(服务端 syncing 由 true→false):清空本次同步记录。
// 这是可靠的收尾时机 —— 不依赖 mutation 的 onSettled(它现在瞬间触发,会误清)。
useEffect(() => {
if (!syncing && syncStartedAt !== null) {
setSyncStartedAt(null)
setSyncSingleTable(null)
}
}, [syncing, syncStartedAt])
// 选中的个股(模糊搜索结果);null 时显示搜索引导
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
const { last: lastStock, remember: rememberStock } = useLastStock('financials')
const pick = (symbol: string, name: string) => {
setSelected({ symbol, name })
rememberStock(symbol, name)
}
if (!hasFinancial) {
return (
<>
<PageHeader title="财务" subtitle="利润表 / 资负表 / 现金流 / 关键指标 · Expert" />
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert" />
<div className="px-8 py-10">
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-warning/10">
@@ -50,6 +70,25 @@ export function Financials() {
<p className="mt-2 text-xs leading-relaxed text-secondary">
Expert
</p>
{/* 当前财务数据源(TickFlow)需付费,后续将接入免费数据源;期间欢迎在 issues 推荐免费源 */}
<div className="mt-5 rounded-btn border border-accent/25 bg-accent/[0.05] px-3.5 py-3 text-left">
<div className="flex items-center gap-1.5 text-xs font-medium text-accent">
<Lightbulb className="h-3.5 w-3.5 shrink-0" />
</div>
<p className="mt-1.5 text-[11px] leading-relaxed text-secondary">
,, Issues ~
</p>
<a
href="https://github.com/shy3130/tickflow-stock-panel/issues"
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-accent hover:underline"
>
Issues
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
</>
@@ -57,15 +96,32 @@ export function Financials() {
}
const handleSync = (table: string) => {
// 防重复点击:syncing 中不再触发(后端 run_now 也有 is_syncing 兜底)
// 防重复点击:syncing 中不再触发(后端 trigger 也有 _is_syncing 兜底)
if (syncing) return
// 记录开始时间: 全量同步判断所有 4 张表, 单表同步只判断这一张
syncStartedAtRef.current = Date.now()
syncSingleTableRef.current = table === 'all' ? null : table
setSyncStartedAt(Date.now())
setSyncSingleTable(table === 'all' ? null : table)
syncMut.mutate(table, {
onSettled: () => {
syncStartedAtRef.current = null
syncSingleTableRef.current = null
onSuccess: (r) => {
// 后端 trigger 立即返回 started 状态;若被防并发跳过(已有同步在进行),
// 给用户明确反馈,并清空本次误设的记录。
if (!r.synced?.started) {
if (r.synced?.reason === 'already running') {
toast('财务数据正在同步中,请稍候', 'success')
} else if (r.synced?.reason === 'no FINANCIAL capability') {
// 能力未就绪:通常发生在升级/刷新 Key 后调度器状态未同步 —— 提示用户检查 Key
toast('财务数据能力未就绪,请检查 API Key 或刷新页面后重试', 'error')
} else {
toast(`同步未能开始${r.synced?.reason ? `:${r.synced.reason}` : ''}`, 'error')
}
setSyncStartedAt(null)
setSyncSingleTable(null)
}
},
onError: () => {
// 请求失败:清空本次记录(request 已弹错误 toast)
setSyncStartedAt(null)
setSyncSingleTable(null)
},
})
}
@@ -74,8 +130,6 @@ export function Financials() {
const available = status?.available ?? false
const lastSync = status?.last_sync ?? {}
// 本次同步进度: 仅当 syncStartedAt 存在且 syncing 时, 按 last_sync 时间戳判断
const syncStartedAt = syncStartedAtRef.current
const syncSingleTable = syncSingleTableRef.current
const isFullSync = syncing && syncStartedAt && !syncSingleTable // 全量同步
const isSingleSync = syncing && syncStartedAt && !!syncSingleTable // 单表同步
const TABLE_ORDER = ['metrics', 'income', 'balance_sheet', 'cash_flow'] as const
@@ -102,10 +156,11 @@ export function Financials() {
return (
<>
<PageHeader
title="财务"
subtitle="利润表 / 资负表 / 现金流 / 关键指标 · Expert"
title="财务分析"
subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert"
right={
<div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={pick} />
{syncing && (
<span className="text-xs text-accent/80 flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" />
@@ -135,7 +190,7 @@ export function Financials() {
{syncing && (
<div className="flex items-center gap-2 rounded-card border border-accent/30 bg-accent/[0.06] px-3 py-2 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
TickFlow
</div>
)}
@@ -217,7 +272,7 @@ export function Financials() {
<div className="rounded-card border border-dashed border-border bg-surface px-6 py-14 text-center">
<Database className="mx-auto h-8 w-8 text-muted" />
<div className="mt-3 text-sm text-secondary"></div>
<div className="mt-1 text-xs text-muted">"全部同步"</div>
<div className="mt-1 text-xs text-muted">"全部同步" TickFlow </div>
</div>
) : (
<>
@@ -227,7 +282,7 @@ export function Financials() {
// 已选股:紧凑搜索条 + 清除按钮(便于换股)
<div className="flex items-center gap-3">
<div className="flex-1 max-w-xl">
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
<StockFinancialSearch onSelect={pick} />
</div>
<button
onClick={() => setSelected(null)}
@@ -246,7 +301,7 @@ export function Financials() {
<span></span>
</div>
<div className="w-full max-w-xl">
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
<StockFinancialSearch onSelect={pick} />
</div>
<div className="text-[11px] text-muted"> 600000 / </div>
</div>
@@ -265,6 +320,9 @@ export function Financials() {
/>
)}
</div>
{/* AI 历史分析报告 */}
{available && <ReportHistoryPanel />}
</>
)}
</div>
+53 -8
View File
@@ -1,10 +1,9 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
Activity,
Crown,
Database,
Layers3,
RefreshCw,
Search,
@@ -14,7 +13,7 @@ import {
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, DimensionHeatmap, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { AnalysisConfigDialog, DimensionHeatmap, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -278,7 +277,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({
@@ -287,6 +290,21 @@ export function IndustryAnalysis() {
enabled: !!activeConfigId,
})
// 内置行业预设 (ext_hy_ths) 手动获取数据
const PRESET_INDUSTRY_ID = 'ext_hy_ths'
const queryClient = useQueryClient()
const fetchMutation = useMutation({
mutationFn: () => api.extDataPresetFetch(PRESET_INDUSTRY_ID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QK.extData })
queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_INDUSTRY_ID, undefined, PAGE_LIMIT) })
},
})
// 是否处于「内置行业预设存在但无数据」状态 → 显示获取按钮
const needsIndustryFetch =
!!activeConfig && activeConfig.id === PRESET_INDUSTRY_ID &&
!rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
@@ -357,11 +375,30 @@ export function IndustryAnalysis() {
}
if (!activeConfig) {
// 极端情况: 无任何行业配置。仍提供一键获取内置行业数据入口
return (
<div className="flex h-full flex-col">
<PageHeader title="行业分析" />
<EmptyState icon={Database} title="暂无行业数据" hint={'请先在"数据"页面创建包含行业/板块字段的扩展数据源'} />
</div>
<>
<div className="flex h-full flex-col">
<PageHeader
title="行业分析"
right={
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
}
/>
<PresetFetchState
title="暂无行业数据"
hint="从同花顺获取行业分类数据后即可使用行业分析"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
</AnimatePresence>
</>
)
}
@@ -427,6 +464,14 @@ export function IndustryAnalysis() {
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div>
) : needsIndustryFetch ? (
<PresetFetchState
title="未获取行业数据"
hint="内置行业数据源已就绪,点击下方按钮从同花顺获取行业分类数据"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
) : (
<EmptyState icon={Layers3} title="未匹配到行业数据" hint={resolved.hint || '请检查扩展数据是否包含行业/板块相关字段'} />
)}
+386 -74
View File
@@ -1,16 +1,16 @@
import { useState, useCallback, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { RefreshCw, ChevronDown, Flame, Settings2, X } from 'lucide-react'
import { RefreshCw, ChevronDown, Flame, Settings2, X, Bell, BellOff, AlertCircle } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { api, type LimitLadderTier, type LimitLadderStock } from '@/lib/api'
import { api, type LimitLadderTier, type LimitLadderStock, type MonitorRule } from '@/lib/api'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPct, priceColorClass } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { useCapabilities } from '@/lib/useSharedQueries'
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns'
@@ -137,54 +137,63 @@ function fmtSealAmount(v: number): string {
// ===== 板块标识 =====
function boardTag(symbol: string): { label: string; cls: string } | null {
if (/^(300|301)/.test(symbol)) return { label: '创', cls: 'text-orange-600 dark:text-orange-400 bg-orange-100 dark:bg-orange-400/12 border-orange-200 dark:border-orange-400/25' }
if (/^688/.test(symbol)) return { label: '科', cls: 'text-cyan-600 dark:text-cyan-400 bg-cyan-100 dark:bg-cyan-400/12 border-cyan-200 dark:border-cyan-400/25' }
if (/\.BJ$/.test(symbol)) return { label: '北', cls: 'text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-400/12 border-purple-200 dark:border-purple-400/25' }
if (/^(300|301)/.test(symbol)) return { label: '创', cls: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' }
if (/^688/.test(symbol)) return { label: '科', cls: 'text-cyan-400 bg-cyan-400/12 border-cyan-400/25' }
if (/\.BJ$/.test(symbol)) return { label: '北', cls: 'text-purple-400 bg-purple-400/12 border-purple-400/25' }
return null
}
// ===== 状态标识 + 卡片样式 =====
const STATUS_STYLE: Record<string, {
cardCls: string
nameCls: string
codeCls: string
badge: string
badgeText: string | ((d: Direction) => string)
}> = {
const STATUS_STYLE: Record<string, { bg: string; bar: string; nameCls: string; codeCls: string; badge: string; badgeText: string | ((d: Direction) => string); cardStyle?: React.CSSProperties; hoverShadow?: string }> = {
limit_up: {
cardCls: 'bg-gradient-to-br from-red-50/90 via-red-50/50 to-transparent dark:from-red-950/40 dark:via-red-900/25 dark:to-transparent border-l-2 border-bull/40 dark:border-bull/50 hover:border-bull/60 dark:hover:border-bull/70 hover:bg-red-100/70 dark:hover:bg-red-950/30',
nameCls: 'text-bull text-[13px]',
codeCls: 'text-secondary dark:text-muted/80',
bg: '',
bar: 'border-l-2 border-bull/50',
nameCls: 'text-rose-50 text-[13px]',
codeCls: 'text-muted/80',
badge: '',
badgeText: '',
cardStyle: {
background: 'linear-gradient(105deg, hsl(4 60% 45% / 0.14) 0%, hsl(6 50% 30% / 0.09) 40%, hsl(220 15% 12% / 0.0) 100%)',
boxShadow: 'inset 1px 0 0 hsl(4 80% 55% / 0.12), 0 0 10px -4px hsl(4 80% 50% / 0.10)',
},
hoverShadow: 'inset 1px 0 0 hsl(4 80% 55% / 0.30), 0 0 18px -4px hsl(4 80% 50% / 0.28)',
},
limit_down: {
cardCls: 'bg-gradient-to-br from-emerald-50/90 via-emerald-50/50 to-transparent dark:from-emerald-950/40 dark:via-emerald-900/25 dark:to-transparent border-l-2 border-bear/40 dark:border-bear/50 hover:border-bear/60 dark:hover:border-bear/70 hover:bg-emerald-100/70 dark:hover:bg-emerald-950/30',
nameCls: 'text-bear text-[13px]',
codeCls: 'text-secondary dark:text-muted/80',
bg: '',
bar: 'border-l-2 border-bear/50',
nameCls: 'text-green-50 text-[13px]',
codeCls: 'text-muted/80',
badge: '',
badgeText: '',
cardStyle: {
background: 'linear-gradient(105deg, hsl(152 60% 45% / 0.14) 0%, hsl(150 50% 30% / 0.09) 40%, hsl(220 15% 12% / 0.0) 100%)',
boxShadow: 'inset 1px 0 0 hsl(152 80% 45% / 0.12), 0 0 10px -4px hsl(152 80% 45% / 0.10)',
},
hoverShadow: 'inset 1px 0 0 hsl(152 80% 45% / 0.30), 0 0 18px -4px hsl(152 80% 45% / 0.28)',
},
broken: {
cardCls: 'opacity-75 border-l border-purple-300 dark:border-purple-400/30 hover:bg-purple-50/60 dark:hover:bg-purple-400/5',
nameCls: 'text-foreground/80 dark:text-foreground/70 text-xs',
codeCls: 'text-muted/70 dark:text-muted/60',
badge: 'text-purple-600 dark:text-purple-400',
bg: 'opacity-75',
bar: 'border-l border-purple-400/30',
nameCls: 'text-foreground/70 text-xs',
codeCls: 'text-muted/60',
badge: 'text-purple-400',
badgeText: d => d === 'down' ? '撬' : '炸',
},
recovery: {
cardCls: 'opacity-75 border-l border-purple-300 dark:border-purple-400/30 hover:bg-purple-50/60 dark:hover:bg-purple-400/5',
nameCls: 'text-foreground/80 dark:text-foreground/70 text-xs',
codeCls: 'text-muted/70 dark:text-muted/60',
badge: 'text-purple-600 dark:text-purple-400',
bg: 'opacity-75',
bar: 'border-l border-purple-400/30',
nameCls: 'text-foreground/70 text-xs',
codeCls: 'text-muted/60',
badge: 'text-purple-400',
badgeText: '撬',
},
failed: {
cardCls: 'opacity-75 border-l border-border dark:border-muted/25 hover:bg-elevated/80 dark:hover:bg-elevated/30',
nameCls: 'text-secondary dark:text-foreground/70 text-xs',
codeCls: 'text-muted/70 dark:text-muted/60',
badge: 'text-muted dark:text-muted/80',
bg: 'opacity-75',
bar: 'border-l border-muted/25',
nameCls: 'text-foreground/70 text-xs',
codeCls: 'text-muted/60',
badge: 'text-muted/80',
badgeText: d => d === 'down' ? '止' : '断',
},
}
@@ -207,13 +216,19 @@ function useSealedDegrade(asOf: string, latestDate: string | undefined, sealedRe
// ===== 单只股票卡片 =====
function StockCard({ stock, extFields, direction, sealMode, onClick }: {
function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick }: {
stock: LimitLadderStock
extFields: ExtFieldConfig
direction: Direction
sealMode: 'vol' | 'amount'
monitored: boolean
monitorRule?: MonitorRule
onMonitorChange: () => void
hasDepth: boolean
onClick: () => void
}) {
const [showMonitorMenu, setShowMonitorMenu] = useState(false)
const [menuAnchor, setMenuAnchor] = useState<DOMRect | null>(null)
const code = stock.symbol.replace(/\.BJ$/, '').replace(/\.SZ$/, '').replace(/\.SH$/, '')
const tag = boardTag(stock.symbol)
const status = stock.status || (direction === 'down' ? 'limit_down' : 'limit_up')
@@ -232,19 +247,58 @@ function StockCard({ stock, extFields, direction, sealMode, onClick }: {
const badgeText = typeof style.badgeText === 'function' ? style.badgeText(direction) : style.badgeText
const tagCls = 'text-[9px] leading-none px-1 py-px rounded-sm'
const conceptCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-amber-700 dark:text-orange-200/80 bg-amber-100 dark:bg-orange-400/10'
const industryCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-sky-700 dark:text-sky-300 bg-sky-100 dark:bg-sky-400/10'
const textCls = `${tagCls} text-secondary/80 bg-elevated dark:text-secondary/60 dark:bg-elevated/60`
const conceptCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-orange-200/60 bg-orange-400/[0.05]'
const industryCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-sky-300/90 bg-sky-400/10'
const textCls = `${tagCls} text-secondary/60 bg-elevated/60`
const hasTags = conceptTags.length > 0 || industryTags.length > 0
// 齿轮始终可见: 让免费用户也能看到功能入口, 点开后在菜单内提示权限不足。
// Pro+ 用户正常设置; 免费用户保存按钮禁用 + 显示升级提示。
return (
<button
<div className="relative group w-full">
{/* 监控设置按钮 (右上角): 不能嵌在卡片 button 内 */}
<button
onClick={e => {
e.stopPropagation()
setMenuAnchor(e.currentTarget.getBoundingClientRect())
setShowMonitorMenu(v => !v)
}}
title={monitored ? '封单监控已开启' : '开启封单监控'}
className={`absolute top-1 right-1 z-20 p-0.5 rounded transition-opacity cursor-pointer ${
monitored ? 'opacity-100 text-amber-400' : 'opacity-0 group-hover:opacity-70 text-muted hover:!opacity-100'
}`}
>
{monitored ? <Bell className="h-3 w-3" /> : <BellOff className="h-3 w-3" />}
</button>
{/* 监控菜单 */}
{showMonitorMenu && menuAnchor && (
<MonitorMenu
stock={stock}
direction={direction}
sealMode={sealMode}
monitorRule={monitorRule}
anchorRect={menuAnchor}
hasDepth={hasDepth}
onClose={() => setShowMonitorMenu(false)}
onChanged={onMonitorChange}
/>
)}
<button
onClick={onClick}
className={`group flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.cardCls}`}
className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''}`}
style={style.cardStyle ? { ...style.cardStyle } : undefined}
onMouseEnter={e => {
if (!style.cardStyle || !style.hoverShadow) return
e.currentTarget.style.boxShadow = style.hoverShadow
}}
onMouseLeave={e => {
if (!style.cardStyle) return
e.currentTarget.style.boxShadow = style.cardStyle.boxShadow ?? ''
}}
>
{/* 名称行 */}
<div className="flex items-center gap-1.5 w-full min-w-0">
<div className="flex items-center gap-1.5 w-full min-w-0 pr-4">
<span className={`${style.nameCls} font-medium truncate`}>{stock.name}</span>
{tag && (
<span className={`shrink-0 text-[9px] px-1 py-px rounded-full border leading-none ${tag.cls}`}>{tag.label}</span>
@@ -267,7 +321,7 @@ function StockCard({ stock, extFields, direction, sealMode, onClick }: {
: fmtSealVol(stock.sealed_vol)}
</span>
) : stock.sealed_status === 'pending' ? (
<span className="text-[9px] text-amber-600/80 dark:text-yellow-500/60 leading-none"></span>
<span className="text-[9px] text-yellow-500/60 leading-none"></span>
) : (
/* 未修正: 显示连板数 */
<span className="text-[10px] font-semibold tabular-nums text-accent/80">
@@ -299,6 +353,228 @@ function StockCard({ stock, extFields, direction, sealMode, onClick }: {
</div>
)}
</button>
</div>
)
}
// ===== 封单监控菜单 =====
function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasDepth, onClose, onChanged }: {
stock: LimitLadderStock
direction: Direction
sealMode: 'vol' | 'amount'
monitorRule?: MonitorRule
anchorRect: DOMRect
hasDepth: boolean
onClose: () => void
onChanged: () => void
}) {
const ruleId = `mr_ladder_${stock.symbol.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}`
const existing = monitorRule
// 推送外部开关默认值: 取偏好设置中的全局默认 (已有规则沿用其值)
const { data: prefs } = usePreferences()
const webhookDefault = prefs?.webhook_enabled_default ?? false
// 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元)
const VOL_UNITS = [
{ key: '1', label: '手', mult: 1 },
{ key: '10000', label: '万手', mult: 10000 },
]
const AMT_UNITS = [
{ key: '1', label: '元', mult: 1 },
{ key: '10000', label: '万元', mult: 10000 },
{ key: '100000000', label: '亿元', mult: 100000000 },
]
const [metric, setMetric] = useState<'sealed_vol' | 'sealed_amount'>(existing?.metric ?? (sealMode === 'amount' ? 'sealed_amount' : 'sealed_vol'))
const units = metric === 'sealed_amount' ? AMT_UNITS : VOL_UNITS
// 已有规则: 反算到最大便捷单位 (选能整除的最大倍率); 新建: 额默认亿元, 量默认万手
const initUnit = (() => {
if (!existing || !existing.threshold) return metric === 'sealed_amount' ? '100000000' : '10000'
const thr = existing.threshold
const matched = [...units].reverse().find(u => thr >= u.mult && thr % u.mult === 0)
return matched ? matched.key : units[0].key
})()
const [unitKey, setUnitKey] = useState(initUnit)
const [threshold, setThreshold] = useState<string>(() => {
if (!existing || !existing.threshold) return ''
const mult = units.find(u => u.key === initUnit)?.mult ?? 1
return String(existing.threshold / mult)
})
const [pushExternal, setPushExternal] = useState(existing?.webhook_enabled ?? webhookDefault)
const [saving, setSaving] = useState(false)
const warnLabel = direction === 'down' ? '翘板预警' : '炸板预警'
// 切 metric 时重置单位 (额默认亿元, 量默认万手) + 清空阈值
const switchMetric = (m: 'sealed_vol' | 'sealed_amount') => {
setMetric(m)
// 额选亿元(key=100000000), 量选万手(key=10000)
const defaultKey = m === 'sealed_amount' ? '100000000' : '10000'
setUnitKey(defaultKey)
setThreshold('')
}
const handleSave = async () => {
const inputValue = Number(threshold)
if (!threshold || isNaN(inputValue) || inputValue < 0) return
const mult = units.find(u => u.key === unitKey)?.mult ?? 1
const thr = Math.round(inputValue * mult) // 换算回原始单位 (量=手, 额=元)
setSaving(true)
try {
await api.monitorRuleSave({
id: ruleId,
name: `封单监控 · ${stock.name ?? stock.symbol}`,
enabled: true,
type: 'ladder',
scope: 'symbols',
symbols: [stock.symbol],
direction: direction === 'down' ? 'down' : 'up',
metric,
threshold: thr,
conditions: [],
logic: 'and',
cooldown_seconds: existing?.cooldown_seconds ?? 600,
severity: 'warn',
message: '',
webhook_enabled: pushExternal,
} as MonitorRule)
onChanged()
onClose()
} catch { /* toast 已在 api 层处理 */ }
finally { setSaving(false) }
}
const handleRemove = async () => {
setSaving(true)
try {
await api.monitorRuleDelete(ruleId)
onChanged()
onClose()
} catch { /* ignore */ }
finally { setSaving(false) }
}
// 基于齿轮按钮位置算菜单坐标 (fixed 定位, 脱离父级 overflow-hidden 裁剪)
const MENU_W = 240 // w-60 = 15rem = 240px
const MENU_H = 340 // 预估高度 (含标题栏 + 4 行设置 + 权限提示 + 按钮区)
const anchorRight = anchorRect.right
const anchorBottom = anchorRect.bottom
// 水平: 默认右对齐齿轮; 超出右边则左移
const left = Math.max(8, Math.min(anchorRight - MENU_W, window.innerWidth - MENU_W - 8))
// 垂直: 默认在齿轮下方; 超出底部则上方
const top = anchorBottom + MENU_H > window.innerHeight
? Math.max(8, anchorRect.top - MENU_H)
: anchorBottom + 4
return (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div
className="fixed z-50 w-60 rounded-lg bg-surface border border-border shadow-xl text-xs overflow-hidden"
style={{ left, top }}
>
{/* 标题栏: 股票名 + 预警类型 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-elevated/40">
<div className="flex items-center gap-1.5 min-w-0">
<Bell className="h-3.5 w-3.5 text-amber-400 shrink-0" />
<span className="font-medium text-foreground truncate">{stock.name ?? stock.symbol}</span>
</div>
<button onClick={onClose} className="text-muted hover:text-foreground shrink-0"><X className="h-3.5 w-3.5" /></button>
</div>
<div className="px-3 py-2.5 space-y-2.5">
{/* 预警类型徽章 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0"></span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${direction === 'down' ? 'bg-bear/15 text-bear' : 'bg-bull/15 text-bull'}`}>
{warnLabel}
</span>
</div>
{/* 监控指标: 段控风格 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0 w-8"></span>
<div className="flex gap-0.5 flex-1 bg-elevated/50 rounded p-0.5">
<button
onClick={() => switchMetric('sealed_vol')}
className={`flex-1 px-2 py-1 rounded text-[11px] transition-colors ${metric === 'sealed_vol' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'}`}
></button>
<button
onClick={() => switchMetric('sealed_amount')}
className={`flex-1 px-2 py-1 rounded text-[11px] transition-colors ${metric === 'sealed_amount' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'}`}
></button>
</div>
</div>
{/* 阈值: 输入 + 单位 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0 w-8"></span>
<input
type="number"
value={threshold}
onChange={e => setThreshold(e.target.value)}
placeholder="≤ 报警"
className="flex-1 min-w-0 h-7 px-2 rounded bg-base border border-border text-foreground text-center tabular-nums placeholder:text-muted/40 focus:outline-none focus:border-accent/50"
/>
<select
value={unitKey}
onChange={e => setUnitKey(e.target.value)}
className="h-7 px-1.5 rounded bg-base border border-border text-secondary text-[11px] focus:outline-none focus:border-accent/50 cursor-pointer"
>
{units.map(u => (
<option key={u.key} value={u.key}>{u.label}</option>
))}
</select>
</div>
{/* 推送渠道: 胶囊标签 (后续可扩展钉钉/企微等), 选中带强调色 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0 w-8"></span>
<button
type="button"
onClick={() => setPushExternal(v => !v)}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-medium transition-colors border cursor-pointer ${
pushExternal
? 'bg-accent/15 text-accent border-accent/40'
: 'bg-elevated/40 text-muted border-border hover:text-secondary'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${pushExternal ? 'bg-accent' : 'bg-muted/50'}`} />
</button>
</div>
{/* 权限提示 (免费用户) */}
{!hasDepth && (
<div className="flex items-start gap-1.5 rounded border border-amber-400/30 bg-amber-400/5 px-2 py-1.5 text-[10px] leading-relaxed text-amber-400/90">
<AlertCircle className="h-3 w-3 shrink-0 mt-px" />
<span> Key ,</span>
</div>
)}
</div>
{/* 底部按钮区 */}
<div className="flex items-center gap-2 px-3 py-2.5 border-t border-border bg-elevated/30">
{existing && (
<button
onClick={handleRemove}
disabled={saving || !hasDepth}
className="shrink-0 h-7 px-2.5 rounded text-[11px] text-muted hover:text-danger hover:bg-danger/5 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
></button>
)}
<button
onClick={handleSave}
disabled={saving || !threshold || !hasDepth}
title={!hasDepth ? '需 Pro+ 套餐 (批量五档能力)' : ''}
className="flex-1 h-7 rounded text-[11px] font-medium transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed bg-accent text-white hover:bg-accent/90 active:scale-[0.98] disabled:active:scale-100"
>
{saving ? '保存中…' : !hasDepth ? '需 Pro+ 套餐' : existing ? '更新监控' : '开启监控'}
</button>
</div>
</div>
</>
)
}
@@ -408,14 +684,14 @@ function loadFilterKeys(): Set<FilterKey> {
const TIER_COLORS: Record<number, string> = {
1: 'border-border',
2: 'border-amber-500/50 dark:border-yellow-600/40',
3: 'border-orange-500/60 dark:border-orange-500/50',
2: 'border-yellow-600/40',
3: 'border-orange-500/50',
}
const TIER_TEXT: Record<number, string> = {
1: 'text-muted',
2: 'text-amber-600 dark:text-yellow-500',
3: 'text-orange-600 dark:text-orange-400',
2: 'text-yellow-500',
3: 'text-orange-400',
}
function tierBorder(n: number): string {
@@ -482,10 +758,10 @@ function OverviewBar({ tiers, dateValue, onDateChange, filterKeys, bf, direction
)
})}
{showBroken && totalBroken > 0 && (
<span className="text-purple-600 dark:text-purple-400 font-medium">{brokenLabel} {totalBroken}</span>
<span className="text-purple-400 font-medium">{brokenLabel} {totalBroken}</span>
)}
{showFailed && totalFailed > 0 && (
<span className="text-amber-600 dark:text-yellow-500 font-medium">{failedLabel} {totalFailed}</span>
<span className="text-yellow-500 font-medium">{failedLabel} {totalFailed}</span>
)}
</div>
<div className="ml-auto">
@@ -502,7 +778,7 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
tiers: LimitLadderTier[]
extFields: ExtFieldConfig
fieldKey: 'concept' | 'industry'
color: 'warning' | 'accent'
color: { text: [number, number, number]; bg: [number, number, number] }
selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null
onSelect: (sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => void
direction: Direction
@@ -529,6 +805,8 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
if (stats.length === 0) return null
const maxCount = stats[0]?.[1] ?? 1
const [r, g, b] = color.text
const [br, bg, bb] = color.bg
const needsExpand = stats.length > 10
return (
@@ -561,16 +839,16 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
onClick={() => onSelect(isSelected ? null : { fieldKey, tag: name })}
className="text-[11px] px-2 py-1 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected ? '#fff' : `hsl(var(--${color}) / ${0.6 + intensity * 0.4})`,
color: isSelected ? '#fff' : `rgba(${r},${g},${b},${0.6 + intensity * 0.4})`,
backgroundColor: isSelected
? `hsl(var(--${color}) / 0.75)`
: `hsl(var(--${color}) / ${0.08 + intensity * 0.22})`,
outline: isSelected ? `1px solid hsl(var(--${color}) / 0.9)` : 'none',
? `rgba(${br},${bg},${bb},0.7)`
: `rgba(${br},${bg},${bb},${intensity * 0.2})`,
outline: isSelected ? `1px solid rgba(${r},${g},${b},0.8)` : 'none',
outlineOffset: 1,
}}
>
{name}
<span className="ml-1" style={{ opacity: isSelected ? 0.85 : 0.65 }}>{count}</span>
<span className="ml-1" style={{ opacity: isSelected ? 0.8 : 0.6 }}>{count}</span>
</button>
)
})}
@@ -589,7 +867,7 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
// ===== 梯队分组 =====
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, direction, sealMode }: {
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth }: {
tier: LimitLadderTier
defaultOpen: boolean
extFields: ExtFieldConfig
@@ -600,6 +878,10 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
onSelectTag: (sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => void
direction: Direction
sealMode: 'vol' | 'amount'
monitoredSymbols: Set<string>
ladderRules: Map<string, MonitorRule>
onMonitorChange: () => void
hasDepth: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
const cfg = { ...DEFAULT_BF, ...bf }
@@ -652,11 +934,11 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
onClick={() => setOpen(v => !v)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-surface/80 transition-colors"
>
<Flame className={`h-3.5 w-3.5 ${tier.boards >= 5 ? 'text-orange-600 dark:text-orange-500' : tier.boards >= 3 ? 'text-amber-600 dark:text-yellow-500' : 'text-muted'}`} />
<Flame className={`h-3.5 w-3.5 ${tier.boards >= 5 ? 'text-orange-500' : tier.boards >= 3 ? 'text-yellow-500' : 'text-muted'}`} />
<span className={`text-sm font-bold tabular-nums ${tierTextCls(tier.boards)}`}>{tierLabel(tier.boards, direction)}<span className="text-muted/40 mx-1">·</span>{luCount}</span>
{(showBroken && brCount > 0) || (showFailed && faCount > 0) ? (
<span className="text-[11px] text-muted/60">
{showBroken && brCount > 0 && <span className="text-purple-600 dark:text-purple-400">{brCount}{brokenBadge}</span>}
{showBroken && brCount > 0 && <span className="text-purple-400">{brCount}{brokenBadge}</span>}
{showBroken && brCount > 0 && showFailed && faCount > 0 && <span className="text-muted/40"> · </span>}
{showFailed && faCount > 0 && <span className="text-muted/80">{faCount}{failedBadge}</span>}
</span>
@@ -681,7 +963,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
<div className="px-3 pt-1 pb-2 space-y-1">
{groupConceptStats.length > 0 && (
<div className="flex flex-wrap gap-1 items-center">
<span className="text-[9px] tracking-wider text-amber-600/70 dark:text-yellow-400/70 mr-0.5"></span>
<span className="text-[9px] tracking-wider text-yellow-400/70 mr-0.5"></span>
{groupConceptStats.slice(0, 20).map(([name, count]) => {
const isSelected = selectedTag?.fieldKey === 'concept' && selectedTag?.tag === name
return (
@@ -690,13 +972,13 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
onClick={() => onSelectTag(isSelected ? null : { fieldKey: 'concept', tag: name })}
className="text-[10px] px-1.5 py-0.5 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected ? '#fff' : 'hsl(var(--warning) / 0.85)',
backgroundColor: isSelected ? 'hsl(var(--warning) / 0.75)' : 'hsl(var(--warning) / 0.12)',
outline: isSelected ? '1px solid hsl(var(--warning) / 0.9)' : 'none',
color: isSelected ? '#fff' : 'rgba(250,204,21,0.8)',
backgroundColor: isSelected ? 'rgba(234,179,8,0.7)' : 'rgba(234,179,8,0.12)',
outline: isSelected ? '1px solid rgba(250,204,21,0.8)' : 'none',
outlineOffset: 1,
}}
>
{name}<span className="ml-0.5" style={{ opacity: isSelected ? 0.85 : 0.65 }}>{count}</span>
{name}<span className="ml-0.5" style={{ opacity: isSelected ? 0.8 : 0.6 }}>{count}</span>
</button>
)
})}
@@ -704,7 +986,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
)}
{groupIndustryStats.length > 0 && (
<div className="flex flex-wrap gap-1 items-center">
<span className="text-[9px] tracking-wider text-blue-600/70 dark:text-blue-400/70 mr-0.5"></span>
<span className="text-[9px] tracking-wider text-blue-400/70 mr-0.5"></span>
{groupIndustryStats.slice(0, 20).map(([name, count]) => {
const isSelected = selectedTag?.fieldKey === 'industry' && selectedTag?.tag === name
return (
@@ -713,13 +995,13 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
onClick={() => onSelectTag(isSelected ? null : { fieldKey: 'industry', tag: name })}
className="text-[10px] px-1.5 py-0.5 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected ? '#fff' : 'hsl(var(--accent) / 0.85)',
backgroundColor: isSelected ? 'hsl(var(--accent) / 0.75)' : 'hsl(var(--accent) / 0.12)',
outline: isSelected ? '1px solid hsl(var(--accent) / 0.9)' : 'none',
color: isSelected ? '#fff' : 'rgba(96,165,250,0.8)',
backgroundColor: isSelected ? 'rgba(59,130,246,0.7)' : 'rgba(59,130,246,0.12)',
outline: isSelected ? '1px solid rgba(96,165,250,0.8)' : 'none',
outlineOffset: 1,
}}
>
{name}<span className="ml-0.5" style={{ opacity: isSelected ? 0.85 : 0.65 }}>{count}</span>
{name}<span className="ml-0.5" style={{ opacity: isSelected ? 0.8 : 0.6 }}>{count}</span>
</button>
)
})}
@@ -737,6 +1019,10 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
return tags.includes(selectedTag.tag)
})
.sort((a, b) => {
// 开启监控的卡片排到分组最前
const ma = monitoredSymbols.has(a.symbol) ? 0 : 1
const mb = monitoredSymbols.has(b.symbol) ? 0 : 1
if (ma !== mb) return ma - mb
const ord = (s: string) => {
if (s === 'limit_up' || s === 'limit_down' || !s) return 0
if (s === 'broken' || s === 'recovery') return 1
@@ -764,6 +1050,10 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
extFields={extFields}
direction={direction}
sealMode={sealMode}
monitored={monitoredSymbols.has(s.symbol)}
monitorRule={ladderRules.get(s.symbol)}
onMonitorChange={onMonitorChange}
hasDepth={hasDepth}
onClick={() => onStockClick(s.symbol, s.name ?? undefined)}
/>
))}
@@ -966,7 +1256,7 @@ function BrokenFailedSection({ bf, onChange }: {
<div className="space-y-3">
{/* 炸板 */}
<div className="space-y-2">
<span className="text-[10px] font-semibold text-purple-600 dark:text-purple-400 uppercase tracking-wider"></span>
<span className="text-[10px] font-semibold text-purple-400 uppercase tracking-wider"></span>
<Toggle label="显示炸板股票" checked={bf.brokenShow ?? true} onChange={v => update({ brokenShow: v })} />
<Toggle label="计入炸板数量" checked={bf.brokenCount ?? true} onChange={v => update({ brokenCount: v })} />
<NumInput label="最低板数(含)" value={bf.brokenMinBoards ?? 0} onChange={v => update({ brokenMinBoards: v ?? 0 })} min={0} max={50} placeholder="0=不限" />
@@ -977,7 +1267,7 @@ function BrokenFailedSection({ bf, onChange }: {
<div className="h-px bg-border" />
{/* 断板 */}
<div className="space-y-2">
<span className="text-[10px] font-semibold text-amber-600 dark:text-yellow-500 uppercase tracking-wider"></span>
<span className="text-[10px] font-semibold text-yellow-500 uppercase tracking-wider"></span>
<Toggle label="显示断板股票" checked={bf.failedShow ?? true} onChange={v => update({ failedShow: v })} />
<Toggle label="计入断板数量" checked={bf.failedCount ?? true} onChange={v => update({ failedCount: v })} />
<NumInput label="最低板数(含)" value={bf.failedMinBoards ?? 0} onChange={v => update({ failedMinBoards: v ?? 0 })} min={0} max={50} placeholder="0=不限" />
@@ -1031,7 +1321,7 @@ function ExtConfigDialog({ fields, onSave, onClose }: {
{/* 三列平铺 */}
<div className="flex gap-0 border-b border-border px-2 overflow-hidden">
<div className="flex-1 min-w-0 p-3 border-r border-border" style={{ minWidth: 180 }}>
<span className="text-[10px] font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wider mb-2 block"></span>
<span className="text-[10px] font-semibold text-sky-400 uppercase tracking-wider mb-2 block"></span>
<ExtFieldSection item={draft.concept} onChange={v => setDraft(d => ({ ...d, concept: v }))} options={options} />
<div className="h-px bg-border my-3" />
<Toggle label="显示概念分布统计" checked={draft.showConceptStats ?? true} onChange={v => setDraft(d => ({ ...d, showConceptStats: v }))} />
@@ -1039,7 +1329,7 @@ function ExtConfigDialog({ fields, onSave, onClose }: {
<Toggle label="显示分组概念统计" checked={draft.showConceptGroupStats ?? false} onChange={v => setDraft(d => ({ ...d, showConceptGroupStats: v }))} />
</div>
<div className="flex-1 min-w-0 p-3 border-r border-border" style={{ minWidth: 180 }}>
<span className="text-[10px] font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wider mb-2 block"></span>
<span className="text-[10px] font-semibold text-blue-400 uppercase tracking-wider mb-2 block"></span>
<ExtFieldSection item={draft.industry} onChange={v => setDraft(d => ({ ...d, industry: v }))} options={options} />
<div className="h-px bg-border my-3" />
<Toggle label="显示行业分布统计" checked={draft.showIndustryStats ?? true} onChange={v => setDraft(d => ({ ...d, showIndustryStats: v }))} />
@@ -1076,6 +1366,24 @@ export function LimitUpLadder() {
const [showConcept, setShowConcept] = useState(() => storage.limitLadderShowExt.get({ concept: true, industry: true }).concept)
const [showIndustry, setShowIndustry] = useState(() => storage.limitLadderShowExt.get({ concept: true, industry: true }).industry)
// 连板梯队封单监控规则 (type=ladder): {symbol → rule} 映射
const { data: monitorRulesData, refetch: refetchMonitorRules } = useQuery({
queryKey: ['monitor-rules'],
queryFn: () => api.monitorRulesList(),
staleTime: 30 * 1000,
})
const ladderRules = useMemo(() => {
const all = monitorRulesData?.rules ?? []
const m = new Map<string, typeof all[number]>()
for (const r of all) {
if (r.type === 'ladder' && r.enabled && r.symbols[0]) {
m.set(r.symbols[0], r)
}
}
return m
}, [monitorRulesData])
const monitoredSymbols = useMemo(() => new Set(ladderRules.keys()), [ladderRules])
const toggleDirection = useCallback((d: Direction) => {
setDirection(d)
storage.limitLadderDirection.set(d)
@@ -1254,9 +1562,9 @@ export function LimitUpLadder() {
{/* 显示组: 概念/行业 */}
<button
onClick={toggleConcept}
className={`px-2 py-1 text-xs transition-colors rounded-sm ${
className={`px-2 py-1 text-xs transition-colors ${
showConcept
? 'bg-amber-100 text-amber-700 dark:bg-yellow-500/15 dark:text-yellow-400 font-medium'
? 'bg-yellow-500/15 text-yellow-400 font-medium'
: 'text-secondary hover:text-foreground hover:bg-surface'
}`}
>
@@ -1264,9 +1572,9 @@ export function LimitUpLadder() {
</button>
<button
onClick={toggleIndustry}
className={`px-2 py-1 text-xs transition-colors rounded-sm ${
className={`px-2 py-1 text-xs transition-colors ${
showIndustry
? 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400 font-medium'
? 'bg-blue-500/15 text-blue-400 font-medium'
: 'text-secondary hover:text-foreground hover:bg-surface'
}`}
>
@@ -1319,7 +1627,7 @@ export function LimitUpLadder() {
tiers={tiers}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
fieldKey="concept"
color="warning"
color={{ text: [250, 204, 21], bg: [234, 179, 8] }}
selectedTag={selectedTag}
onSelect={handleSelectTag}
direction={direction}
@@ -1332,7 +1640,7 @@ export function LimitUpLadder() {
tiers={tiers}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
fieldKey="industry"
color="accent"
color={{ text: [96, 165, 250], bg: [59, 130, 246] }}
selectedTag={selectedTag}
onSelect={handleSelectTag}
direction={direction}
@@ -1354,6 +1662,10 @@ export function LimitUpLadder() {
onSelectTag={handleSelectTag}
direction={direction}
sealMode={sealMode}
monitoredSymbols={monitoredSymbols}
ladderRules={ladderRules}
onMonitorChange={refetchMonitorRules}
hasDepth={sealedDegrade.hasDepth}
/>
))}
</div>
+28 -4
View File
@@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'
import { RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { api, type MonitorRule, type AlertEvent } from '@/lib/api'
import { api, type MonitorRule, type AlertEvent, type MonitorCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtPrice, fmtPct } from '@/lib/format'
import { cn } from '@/lib/cn'
@@ -367,9 +367,33 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
})()}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
</div>
{/* 详情行: 命中条件 (signal/price/market) + 当前价 / 或默认消息 */}
{(ev.conditions && ev.conditions.length > 0) ? (
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px]">
<span className="text-muted"></span>
{ev.conditions.map((c: MonitorCondition, ci: number) => (
<span key={ci} className="inline-flex items-center gap-0.5">
{ci > 0 && <span className="text-secondary">{ev.logic === 'or' ? '或' : '且'}</span>}
{c.op === 'truth' ? (
<span className="text-accent/80">{cnSignal(c.field)}</span>
) : (
<span className="text-foreground/80 font-mono">{cnSignal(c.field)}{c.op}{c.value}</span>
)}
</span>
))}
{ev.price != null && (
<>
<span className="text-muted">·</span>
<span className="text-muted"></span>
<span className="font-mono text-foreground/90">{fmtPrice(ev.price)}</span>
</>
)}
</div>
) : (
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
</div>
)}
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{ev.signals.map((s: string, j: number) => (
+125 -47
View File
@@ -21,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'
@@ -36,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() {
@@ -101,7 +108,7 @@ export function Onboarding() {
className="shrink-0"
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
/>
<span className="text-sm font-semibold tracking-tight">Stock Panel</span>
<span className="text-sm font-semibold tracking-tight">TickFlow Stock Panel</span>
</div>
{/* 步骤进度条 —— 胶囊式 */}
<div className="flex items-center gap-1.5">
@@ -172,27 +179,31 @@ function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi
</motion.div>
<h1 className="mt-6 text-3xl font-bold text-foreground tracking-tight">
使 Stock Panel
使 TickFlow Stock Panel
</h1>
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
A
,使
</p>
{/* 6 个特性卡片 */}
<div className="mt-8 grid grid-cols-2 sm:grid-cols-3 gap-3 text-left">
{/* 特性卡片 —— 3×3 网格,横向布局压缩高度 */}
<div className="mt-8 grid grid-cols-2 sm:grid-cols-3 gap-2.5 text-left">
{HIGHLIGHTS.map((h, i) => (
<motion.div
key={h.title}
initial={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05 * i + 0.1 }}
transition={{ duration: 0.3, delay: 0.04 * i + 0.1 }}
whileHover={{ y: -2 }}
className="group rounded-card border border-border bg-surface/80 backdrop-blur-sm p-3.5 transition-colors hover:border-accent/30"
className="group flex items-start gap-2.5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-2.5 transition-colors hover:border-accent/30"
>
<h.icon className={`h-5 w-5 ${h.tint} transition-transform group-hover:scale-110`} />
<div className="mt-2 text-sm font-medium text-foreground">{h.title}</div>
<div className="mt-1 text-xs text-muted leading-relaxed">{h.desc}</div>
<div className="rounded-lg bg-elevated/50 p-1.5 shrink-0">
<h.icon className={`h-4 w-4 ${h.tint} transition-transform group-hover:scale-110`} />
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-foreground">{h.title}</div>
<div className="mt-0.5 text-[11px] text-muted leading-snug line-clamp-2">{h.desc}</div>
</div>
</motion.div>
))}
</div>
@@ -216,7 +227,7 @@ function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi
)
}
// ===== Step 1: 输入数据源 Key =====
// ===== Step 1: 输入 TickFlow Key =====
function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () => void; onBack: () => void }) {
const qc = useQueryClient()
@@ -240,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 (
@@ -249,26 +260,47 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
<div className="rounded-lg bg-accent/10 p-2">
<ShieldCheck className="h-4 w-4 text-accent" />
</div>
<h2 className="text-xl font-bold text-foreground"> API Key</h2>
<h2 className="text-xl font-bold text-foreground"> TickFlow API Key</h2>
</div>
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
Key 使 Key <span className="font-medium text-foreground"> </span>
使K; Key
TickFlow ,
,{' '}
<a
href="mailto:415333856@qq.com"
className="text-accent hover:underline font-medium"
>
415333856@qq.com
</a>
</p>
{/* 注册引导 */}
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-4 text-xs text-secondary leading-relaxed">
Key?{' '}
<a
href="https://tickflow.org/auth/register"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline inline-flex items-baseline gap-0.5 font-medium"
>
<ExternalLink className="h-3 w-3 self-center" />
</a>{' '}
,使
{/* 档位对比说明 —— None 档 vs Free 档 */}
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{/* None 档 —— 不配置时默认 */}
<div className="rounded-card border border-accent/20 bg-accent/[0.04] p-3">
<div className="flex items-center gap-1.5">
<span className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-bold font-mono bg-accent/15 text-accent/70">None</span>
<span className="text-xs font-medium text-foreground">()</span>
</div>
<ul className="mt-2 space-y-1 text-[11px] text-muted leading-relaxed">
<li>· K数据,</li>
<li>· , 1-2 </li>
<li>· </li>
</ul>
</div>
{/* Free 档 —— 免费注册即可获取 */}
<div className="rounded-card border border-accent/35 bg-accent/[0.08] p-3">
<div className="flex items-center gap-1.5">
<span className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-bold font-mono bg-accent/15 text-accent">Free</span>
<span className="text-xs font-medium text-foreground"></span>
<span className="inline-flex items-center rounded-full bg-accent px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm shadow-accent/30"></span>
</div>
<ul className="mt-2 space-y-1 text-[11px] text-secondary leading-relaxed">
<li>· ,</li>
<li>· K + </li>
<li>· </li>
</ul>
</div>
</div>
{/* Key 已配置提示 */}
@@ -282,6 +314,27 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
</div>
)}
{/* 获取 Key 的说明 —— 黄框卡片 */}
<div className="mt-4 flex items-start gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2.5 text-xs text-foreground leading-relaxed">
<AlertCircle className="h-4 w-4 shrink-0 text-warning mt-px" />
<span>
Key {' '}
<a
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
target="_blank"
rel="noreferrer"
className="text-warning hover:underline inline-flex items-baseline gap-0.5 font-medium"
>
tickflow.org
<ExternalLink className="h-3 w-3 self-center" />
</a>
<span className="block mt-1.5 text-foreground/70">
TickFlow ,
</span>
</span>
</div>
{/* 输入 */}
<form
onSubmit={(e) => {
@@ -293,7 +346,7 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
<div className="relative">
<input
type={revealing ? 'text' : 'password'}
placeholder={alreadyHasKey ? '粘贴新 Key 替换当前' : '粘贴数据源 API Key'}
placeholder={alreadyHasKey ? '粘贴新 Key 替换当前' : '粘贴 TickFlow API Key'}
value={keyInput}
onChange={(e) => {
setKeyInput(e.target.value)
@@ -379,7 +432,7 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
const settings = useSettings()
const caps = useCapabilities()
// 是否配置成功 —— 免费档(free)或付费档(api_key)都算;无档(none)算未配置
// 是否配置成功 —— 免费档(free)或付费档(api_key)都算;None算未配置
const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key'
const capList = caps.data ? Object.entries(caps.data.capabilities) : []
@@ -438,10 +491,10 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
<div className="mx-auto w-fit rounded-xl bg-elevated p-3">
<Zap className="h-6 w-6 text-warning" />
</div>
<div className="mt-3 text-sm font-medium text-foreground"></div>
<div className="mt-3 text-sm font-medium text-foreground"> None </div>
<p className="mt-2 text-xs text-muted leading-relaxed max-w-sm mx-auto">
Key,使K数据 Key ,
<span className="text-foreground font-medium"> </span>
Key,使 1 K数据 Key ,
<span className="text-foreground font-medium"> </span>
</p>
</div>
)}
@@ -470,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 (
@@ -500,18 +559,37 @@ function FinishStep({ onNext, onBack, pending }: { onNext: () => void; onBack: (
<h1 className="mt-6 text-2xl font-bold text-foreground">!</h1>
<p className="mt-2.5 text-sm text-secondary leading-relaxed max-w-md mx-auto">
,
<span className="text-foreground font-medium"> </span>
{hasKey
? 'Key 已生效,进入面板后系统会自动引导你获取行情数据,完成后即可使用全部功能。'
: '当前为 None 档,进入面板后系统会自动引导你获取历史日K数据(无需 Key),即可开始体验。'}
</p>
{/* 快速上手提示 */}
<div className="mt-6 space-y-2 text-left">
{/* 首要行动:获取数据 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
className="mt-5 flex items-start gap-2.5 rounded-card border border-accent/30 bg-accent/[0.06] px-4 py-3 text-left"
>
<div className="rounded-lg bg-accent/15 p-1.5 shrink-0 mt-px">
<Database className="h-4 w-4 text-accent" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">下一步:获取行情数据</div>
<p className="mt-1 text-xs text-secondary leading-relaxed">
, 1 A K( 5500 , 1-3 )
</p>
</div>
</motion.div>
{/* 快速上手入口 */}
<div className="mt-4 space-y-2 text-left">
{tips.map((t, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.1 * i + 0.2 }}
transition={{ duration: 0.3, delay: 0.1 * i + 0.3 }}
className="flex items-center gap-3 rounded-card border border-border bg-surface/80 backdrop-blur-sm px-3.5 py-2.5"
>
<div className="rounded-lg bg-accent/10 p-1.5 shrink-0">
+827
View File
@@ -0,0 +1,827 @@
/**
* 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<AiReviewReport | null>(null) // 查看历史报告
const reportEndRef = useRef<HTMLDivElement>(null)
// 看板数据(与总览页同源)
const marketQuery = useQuery<OverviewMarket>({
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 (
<>
<PageHeader
title="AI 复盘"
titleExtra={<Sparkles className="h-4 w-4 text-accent" />}
subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`}
right={
<div className="flex items-center gap-1">
<button
onClick={() => { marketQuery.refetch() }}
disabled={marketQuery.isFetching}
className="inline-flex items-center gap-1 rounded-btn border border-border bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground disabled:opacity-50"
title="刷新市场数据"
>
<RefreshCw className={cn('h-3 w-3', marketQuery.isFetching && 'animate-spin')} />
</button>
<button
onClick={openSchedule}
className={cn(
'inline-flex items-center gap-1 rounded-btn border px-2 py-1 text-[11px] transition-colors',
reviewSched.enabled
? 'border-accent/40 bg-accent/10 text-accent hover:bg-accent/20'
: 'border-border bg-elevated text-secondary hover:text-foreground',
)}
title={reviewSched.enabled ? `定时复盘已开启 · 每日 ${String(reviewSched.hour).padStart(2,'0')}:${String(reviewSched.minute).padStart(2,'0')}` : '定时复盘'}
>
<Clock className="h-3 w-3" />
</button>
<button
onClick={generate}
disabled={isGenerating}
className={cn(
'inline-flex items-center gap-1.5 rounded-btn px-3.5 py-1.5 text-xs font-medium transition-all',
isGenerating
? 'border border-accent/40 bg-accent/10 text-accent cursor-not-allowed'
: 'bg-accent text-white shadow-sm shadow-accent/25 hover:bg-accent/90 hover:shadow hover:shadow-accent/30',
)}
>
{isGenerating ? (
<><RefreshCw className="h-3.5 w-3.5 animate-spin" /></>
) : (
<><Sparkles className="h-3.5 w-3.5" /></>
)}
</button>
</div>
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_15%_-5%,rgba(59,130,246,0.10),transparent_30%),radial-gradient(circle_at_85%_5%,rgba(139,92,246,0.08),transparent_30%)] px-4 py-4 sm:px-6">
<div className="mx-auto max-w-[1280px] space-y-3">
{marketQuery.isLoading && !data ? (
<div className="flex h-40 items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted">
<RefreshCw className="h-4 w-4 animate-spin" />
</div>
</div>
) : !data || !data.as_of ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-card border border-border bg-surface/80 px-6 py-16">
<div className="relative">
<div className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
<Database className="h-6 w-6 text-accent" strokeWidth={1.8} />
</div>
</div>
<div className="text-center">
<div className="text-sm font-medium text-foreground"></div>
<p className="mt-1 text-xs text-muted"> K ,</p>
</div>
<Link
to="/data"
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-all hover:bg-accent/90 hover:shadow"
>
<Database className="h-3.5 w-3.5" />
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</div>
) : (
<>
{/* ===== 市场摘要条(轻量上下文,非重复看板)===== */}
<MarketSummaryBar data={data} />
{/* ===== 关注点输入 ===== */}
<div className="flex items-center gap-2 rounded-card border border-border bg-surface/80 px-3.5 py-2.5 transition-colors focus-within:border-accent/40">
<Wand2 className="h-3.5 w-3.5 shrink-0 text-accent" />
<input
value={focus}
onChange={(e) => 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 && (
<button onClick={() => setFocus('')} className="text-xs text-muted transition-colors hover:text-foreground"></button>
)}
</div>
{/* ===== 报告 + 历史 双栏(报告为主体)===== */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[1fr_18rem]">
<ReportPanel
phase={phase}
content={displayContent}
error={error}
isGenerating={isGenerating}
viewing={viewing}
onCopy={copyContent}
onDownload={downloadContent}
onRegenerate={generate}
reportEndRef={reportEndRef}
/>
<HistoryPanel
reports={historyQuery.data?.reports ?? []}
loading={historyQuery.isLoading}
viewingId={viewing?.id ?? null}
generating={isGenerating}
onView={viewReport}
onBackToGenerating={() => setViewing(null)}
onDelete={(id) => deleteMut.mutate(id)}
/>
</div>
</>
)}
</div>
</div>
{/* ===== 定时复盘设置弹窗 ===== */}
<AnimatePresence>
{showSchedule && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setShowSchedule(false)}
>
<motion.div
initial={{ scale: 0.96, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.96, opacity: 0 }}
className="w-full max-w-md rounded-card border border-border bg-surface p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<button
onClick={() => setShowSchedule(false)}
className="rounded p-1 text-muted transition-colors hover:bg-elevated hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="mb-4 text-[11px] leading-relaxed text-muted">
,,
;
</p>
{/* 开关(只改本地草稿, 不提交) */}
<label className="flex items-center justify-between rounded-btn bg-elevated/40 px-3 py-2.5">
<span className="text-xs text-foreground"></span>
<button
type="button"
role="switch"
aria-checked={draft.enabled}
onClick={() => setDraft(d => ({ ...d, enabled: !d.enabled }))}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
draft.enabled ? 'bg-accent' : 'bg-border',
)}
>
<span className={cn('inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform', draft.enabled ? 'translate-x-[18px]' : 'translate-x-1')} />
</button>
</label>
{/* 时间设置(仅开启时可编辑, 本地草稿) */}
{draft.enabled && (
<div className="mt-3 flex items-center gap-2 rounded-btn bg-elevated/40 px-3 py-2.5">
<span className="text-[11px] text-muted"></span>
<input
type="number" min={0} max={23} value={draft.hour}
onChange={e => 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"
/>
<span className="text-xs text-muted">:</span>
<input
type="number" min={0} max={59} value={draft.minute}
onChange={e => 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"
/>
<span className="text-[10px] text-muted/70"> 15:00 · </span>
</div>
)}
{/* 推送渠道(多选, 独立常驻, 与定时无关, 即时生效) */}
<div className="mt-3 rounded-btn bg-elevated/40 px-3 py-2.5">
<div className="flex items-center justify-between">
<span className="text-xs text-foreground"></span>
<span className="text-[10px] text-muted/70">{reviewPushChannels.length === 0 ? '未开启' : `${reviewPushChannels.length} 个渠道`}</span>
</div>
<div className="mt-2 space-y-1.5">
{/* 飞书(可用, 多选) */}
<button
type="button"
disabled={pushMut.isPending}
onClick={() => togglePushChannel('feishu')}
className={cn(
'flex w-full items-center gap-2 rounded-btn border px-2.5 py-1.5 text-left transition-colors disabled:opacity-50',
reviewPushChannels.includes('feishu')
? 'border-accent/40 bg-accent/10'
: 'border-border/60 bg-base/40 hover:bg-base/60',
)}
>
<span className={cn('flex h-3 w-3 shrink-0 items-center justify-center rounded border', reviewPushChannels.includes('feishu') ? 'border-accent bg-accent text-white' : 'border-border')}>
{reviewPushChannels.includes('feishu') && <Check className="h-2.5 w-2.5" />}
</span>
<span className="text-[11px] text-foreground"></span>
<span className="text-[9px] text-muted"></span>
<span className={cn('ml-auto text-[9px]', feishuConfigured ? 'text-emerald-500' : 'text-warning')}>
{feishuConfigured ? '已配置' : '未配置'}
</span>
</button>
{/* 微信(开发中, 占位不可选) */}
<div className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-1.5 opacity-60">
<span className="flex h-3 w-3 shrink-0 items-center justify-center rounded border border-border" />
<span className="text-[11px] text-secondary"></span>
<span className="text-[9px] text-muted">/</span>
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</div>
</div>
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
Webhook
{reviewPushChannels.includes('feishu') && !feishuConfigured && (
<Link to="/settings?tab=monitoring" className="ml-1 text-accent hover:underline" onClick={() => setShowSchedule(false)}>
</Link>
)}
</p>
</div>
{!draft.enabled && (
<p className="mt-3 text-[10px] text-muted/70">
当前: 已关闭
</p>
)}
{/* 操作区: 取消 + 保存(统一提交开关+时间) */}
<div className="mt-5 flex justify-end gap-2">
<button
onClick={() => setShowSchedule(false)}
className="rounded-btn bg-elevated px-4 py-1.5 text-xs text-secondary transition-colors hover:text-foreground"
>
</button>
<button
onClick={() => reviewMut.mutate({ enabled: draft.enabled, hour: draft.hour, minute: draft.minute })}
disabled={reviewMut.isPending}
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
>
{reviewMut.isPending ? '保存中…' : '保存'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}
// ================================================================
// 市场摘要条 —— 复盘页的轻量上下文(非重复看板)
// 仅一行:三大指数涨跌 · 情绪分 · 涨停结构 · 成交额
// 详细数据请去 Dashboard 看,这里只给 AI 报告提供背景参照
// ================================================================
// 指数简称映射:全称太长(上证指数/深证成指/创业板指/科创综指)摘要条放不下,统一缩成单字
const INDEX_SHORT: Record<string, string> = {
'上证指数': '上', '深证成指': '深', '创业板指': '创', '科创综指': '科', '科创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 (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-card border border-border bg-surface/80 px-4 py-2.5">
{/* 情绪分(带色徽章)—— 复盘的核心定调 */}
<div className="flex items-center gap-2">
<span
className="grid h-8 w-8 shrink-0 place-items-center rounded font-mono text-xs font-bold tabular-nums"
style={{ color: emoColor, backgroundColor: `${emoColor}1a` }}
>
{score ?? '—'}
</span>
<div className="leading-tight">
<div className="text-[11px] font-medium text-foreground">{data.emotion?.label ?? '情绪'}</div>
<div className="text-[9px] text-secondary"></div>
</div>
</div>
<div className="hidden h-7 w-px bg-border sm:block" />
{/* 四大指数(简称:上深创科)*/}
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
{indices.map(idx => (
<div key={idx.symbol} className="flex items-center gap-1">
<span className="text-[11px] text-secondary">{indexShort(idx.name, idx.symbol)}</span>
<span className={cn('font-mono text-[11px] font-semibold tabular-nums', pctClass(idx.change_pct))}>
{fmtPctAlready(idx.change_pct, 2, true)}
</span>
</div>
))}
</div>
<div className="hidden h-7 w-px bg-border sm:block" />
{/* 涨跌结构 */}
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-secondary"></span>
<span className="font-mono font-semibold text-bull">{data.breadth?.up ?? 0}</span>
<span className="text-muted">/</span>
<span className="font-mono font-semibold text-bear">{data.breadth?.down ?? 0}</span>
</div>
{/* 涨停结构 */}
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-secondary"></span>
<span className="font-mono font-semibold text-bull">{data.limit?.limit_up ?? 0}</span>
<span className="text-secondary"> {(data.limit?.seal_rate ?? 0).toFixed(0)}%</span>
</div>
{/* 成交额 */}
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-secondary"></span>
<span className="font-mono font-semibold text-foreground">{fmtBigNum(data.amount?.total)}</span>
</div>
</div>
)
}
// ================================================================
// 报告面板(流式 + 错误 + 历史/完成态)
// ================================================================
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<HTMLDivElement>
}) {
if (phase === 'error') {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-card border border-border bg-surface/80 px-6 py-14">
<div className="grid h-12 w-12 place-items-center rounded-full bg-danger/10">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="text-sm font-medium text-foreground"></div>
<div className="max-w-md text-center text-xs text-secondary">{error || '请检查 AI 配置后重试'}</div>
<button
onClick={onRegenerate}
className="mt-1 inline-flex items-center gap-1.5 rounded-btn bg-accent/15 px-3 py-1.5 text-xs text-accent transition-colors hover:bg-accent/20"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)
}
if (phase === 'idle' && !content) {
return (
<div className="flex min-h-[28rem] flex-col items-center justify-center gap-5 rounded-card border border-border bg-surface/80 px-6 py-16">
<div className="relative">
<div className="grid h-20 w-20 place-items-center rounded-2xl bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
<BookOpenCheck className="h-9 w-9 text-accent" strokeWidth={1.8} />
</div>
<Sparkles className="absolute -right-1 -top-1 h-5 w-5 text-accent" />
</div>
<div className="text-center">
<div className="text-base font-semibold text-foreground">AI </div>
<p className="mx-auto mt-2 max-w-sm text-xs leading-relaxed text-secondary">
,
</p>
</div>
{/* 报告七节预览 —— 空状态也有内容感,暗示报告结构 */}
<div className="mt-2 grid w-full max-w-md grid-cols-2 gap-2 sm:grid-cols-4">
{[
{ icon: '🎯', label: '一句话定调' },
{ icon: '📊', label: '盘面总览' },
{ icon: '🔥', label: '板块主线' },
{ icon: '💰', label: '资金情绪' },
{ icon: '📰', label: '消息催化' },
{ icon: '🎯', label: '明日计划' },
{ icon: '⚠️', label: '风险提示' },
].map((s) => (
<div key={s.label} className="flex flex-col items-center gap-1 rounded-btn bg-elevated/40 px-2 py-2">
<span className="text-base">{s.icon}</span>
<span className="text-[10px] text-secondary">{s.label}</span>
</div>
))}
</div>
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-muted">
<Sparkles className="h-3 w-3 text-accent" />
</div>
</div>
)
}
// 仅当显示生成内容(非查看历史)且正在生成时,才显示流式光标
const showCursor = isGenerating && !viewing
// 查看历史时(即使后台在生成)也能复制/下载该历史报告
const showActions = !!content && (!isGenerating || !!viewing)
const showViewingTag = !!viewing
const isLoading = phase === 'loading' && !content
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="overflow-hidden rounded-card border border-border bg-surface/80"
>
<div className="flex items-center justify-between border-b border-border bg-gradient-to-r from-accent/5 to-transparent px-4 py-2.5">
<div className="flex items-center gap-1.5">
{isGenerating ? <RefreshCw className="h-3.5 w-3.5 animate-spin text-accent" /> : <BookOpenCheck className="h-3.5 w-3.5 text-accent" />}
<span className="text-xs font-medium text-foreground">
{showViewingTag ? `历史复盘 · ${viewing!.as_of}` : isGenerating ? 'AI 正在复盘…' : '复盘报告'}
</span>
</div>
{showActions && (
<div className="flex items-center gap-1">
<button onClick={onCopy} className="inline-flex items-center gap-1 rounded-btn bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground hover:bg-elevated/70" title="复制全文">
<Copy className="h-3 w-3" />
</button>
<button onClick={onDownload} className="inline-flex items-center gap-1 rounded-btn bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground hover:bg-elevated/70" title="下载为 Markdown">
<Download className="h-3 w-3" />
</button>
</div>
)}
</div>
<div className="max-h-[calc(100vh-22rem)] overflow-y-auto px-5 py-4">
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<div className="relative">
<div className="grid h-11 w-11 place-items-center rounded-full bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
<Sparkles className="h-5 w-5 animate-pulse text-accent" />
</div>
<RefreshCw className="absolute -inset-1 h-13 w-13 animate-spin text-accent/30" style={{ animationDuration: '3s' }} />
</div>
<div className="text-sm text-foreground">AI </div>
<div className="text-xs text-secondary"> · · · </div>
</div>
) : (
<div className="prose prose-invert max-w-none">
<MarkdownRenderer content={content} />
{showCursor && (
<span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-accent align-middle" />
)}
</div>
)}
<div ref={reportEndRef} />
</div>
</motion.div>
)
}
// ================================================================
// 历史面板
// ================================================================
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 (
<div className="overflow-hidden rounded-card border border-border bg-surface/80">
<div className="flex items-center gap-1.5 border-b border-border bg-gradient-to-r from-accent/5 to-transparent px-3 py-2.5">
<History className="h-3.5 w-3.5 text-accent" />
<span className="text-xs font-medium text-foreground"></span>
<span className="font-mono text-[10px] text-muted">({reports.length})</span>
</div>
<div className="max-h-[calc(100vh-26rem)] overflow-y-auto p-2">
{loading ? (
<div className="grid h-20 place-items-center"><RefreshCw className="h-4 w-4 animate-spin text-muted" /></div>
) : empty ? (
<div className="flex flex-col items-center justify-center gap-2 px-3 py-10 text-center">
<History className="h-7 w-7 text-muted/40" strokeWidth={1.5} />
<div className="text-[11px] text-muted"></div>
<div className="text-[10px] text-muted/60"></div>
</div>
) : (
<div className="space-y-1">
{/* 生成中占位项:列表顶部,点击回到正在生成的流式内容 */}
{generating && (
<div
className={cn(
'flex items-center gap-2 rounded px-2 py-2 cursor-pointer transition-colors',
viewingId === null ? 'bg-accent/10 ring-1 ring-accent/20' : 'hover:bg-elevated/60',
)}
onClick={onBackToGenerating}
>
<div className="grid h-8 w-8 shrink-0 place-items-center rounded bg-accent/15">
<RefreshCw className="h-3.5 w-3.5 animate-spin text-accent" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[11px] font-medium text-accent"></div>
<div className="mt-0.5 truncate text-[10px] text-secondary">AI </div>
</div>
</div>
)}
{reports.map((r) => {
const color = scoreColor(r.emotion_score)
return (
<div
key={r.id}
className={cn(
'group flex items-center gap-2 rounded px-2 py-2 cursor-pointer transition-colors',
viewingId === r.id ? 'bg-accent/10 ring-1 ring-accent/20' : 'hover:bg-elevated/60',
)}
onClick={() => onView(r)}
>
<div
className="grid h-8 w-8 shrink-0 place-items-center rounded font-mono text-[10px] font-bold tabular-nums"
style={{ color, backgroundColor: `${color}1a` }}
>
{r.emotion_score ?? '—'}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[11px] font-medium text-foreground">{r.emotion_label ?? '—'}</span>
<span className="font-mono text-[10px] text-secondary">{r.as_of}</span>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
{r.summary
? (() => {
const pcts = parseIndexPcts(shortenIndexNames(r.summary).split('|')[0])
if (pcts.length === 0) {
return <span className="truncate text-[10px] text-secondary">{r.content.slice(0, 40)}</span>
}
return pcts.map((p) => (
<span key={p.name} className="inline-flex items-center gap-0.5 text-[10px]">
<span className="text-secondary">{p.name}</span>
<span className={cn('font-mono font-medium tabular-nums', pctClass(p.pctNum))}>{p.pctStr}</span>
</span>
))
})()
: <span className="truncate text-[10px] text-secondary">{r.content.slice(0, 40)}</span>}
</div>
{r.created_at && (
<div className="mt-0.5 font-mono text-[9px] text-muted">{fmtArchivedAt(r.created_at)}</div>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); onDelete(r.id) }}
className="shrink-0 p-1 text-muted opacity-0 transition-all hover:text-bear group-hover:opacity-100"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
+3 -3
View File
@@ -424,7 +424,7 @@ export function Screener() {
},
})
// 开发用:重载策略文件
// 重新运行策略:重载策略文件 + 重跑全部策略,刷新符合条件的个股
const reloadStrategies = useMutation({
mutationFn: api.strategyReload,
onSuccess: () => {
@@ -496,11 +496,11 @@ export function Screener() {
subtitle="基于本地 enriched 表 · 毫秒级 SQL"
right={
<div className="flex items-center gap-2">
{/* 开发用:重载策略 */}
{/* 重新运行策略:重载策略文件并重跑全部策略,更新命中个股 */}
<button
onClick={() => reloadStrategies.mutate()}
disabled={reloadStrategies.isPending}
title="重载策略文件(开发用)"
title="重新加载策略并运行全部策略,刷新当前符合条件的个股"
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-btn
border border-border bg-surface text-xs font-medium text-muted
hover:text-accent hover:border-accent/50 transition-colors cursor-pointer
+3 -2
View File
@@ -5,7 +5,8 @@
*/
import { useSearchParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import { BarChart3, Radio, SlidersHorizontal, Sparkles, Settings2, Zap } from 'lucide-react'
import { BarChart3, Key, Radio, SlidersHorizontal, Sparkles, Settings2, Zap } from 'lucide-react'
import { SettingsKeysPanel } from './settings/Keys'
import { SettingsAIPanel } from './settings/AI'
import { SettingsMonitoringPanel } from './settings/Monitoring'
import { SettingsExtPagesPanel } from './settings/ExtPages'
@@ -18,7 +19,7 @@ import { cn } from '@/lib/cn'
// ===== Tab 定义 =====
const TABS = [
// { key: 'account', label: '数据源', icon: Key, panel: SettingsKeysPanel },
{ key: 'account', label: 'TickFlow', icon: Key, panel: SettingsKeysPanel },
{ key: 'ai', label: 'AI 设置', icon: Sparkles, panel: SettingsAIPanel },
{ key: 'monitoring', label: '实时监控', icon: Radio, panel: SettingsMonitoringPanel },
{ key: 'ext-pages', label: '扩展页面', icon: BarChart3, panel: SettingsExtPagesPanel },
+312 -4
View File
@@ -1,10 +1,318 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Sparkles, LineChart, History as HistoryIcon, Loader2, ExternalLink, Bell } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { LastStockChip } from '@/components/LastStockChip'
import { AnalysisKChart, type PriceLevel, type LevelType } from '@/components/stock-analysis/AnalysisKChart'
import { api } from '@/lib/api'
import { useLastStock } from '@/lib/useLastStock'
import { QK } from '@/lib/queryKeys'
import { toast } from '@/components/Toast'
import {
startAnalysis, findTodayReport, useHistoryReports,
deleteReport, openHistoryReport,
} from '@/lib/stockAnalysisStore'
/**
* K + (////)+ AI
*
* :
* - + ( K ,)
* - AI / ()
* - ,()
*/
export function StockAnalysis() {
const [symbol, setSymbol] = useState<string>('')
const [name, setName] = useState<string>('')
const [checking, setChecking] = useState(false)
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
const [showHistory, setShowHistory] = useState(false)
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const { last: lastStock, remember: rememberStock } = useLastStock('stock-analysis')
const onSelect = (sym: string, nm: string) => {
setSymbol(sym)
setName(nm)
setShowHistory(false)
setConfirmReport(null)
rememberStock(sym, nm)
}
const handleAnalyze = async () => {
if (!symbol || checking) return
setChecking(true)
try {
// 当日已分析过 → 二次确认(查看今日报告 / 重新分析)
const today = await findTodayReport(symbol)
if (today) {
setConfirmReport({ id: today.id, created_at: today.created_at, focus: today.focus })
} else {
await doAnalysis()
}
} catch {
await doAnalysis()
} finally {
setChecking(false)
}
}
const doAnalysis = async () => {
const r = await startAnalysis(symbol, name)
if (r.error) toast(r.error, 'error')
}
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<h2 className="text-lg text-secondary"></h2>
<p className="mt-2 text-sm text-muted">...</p>
<>
<PageHeader
title="个股分析"
titleExtra={
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400">
Beta
</span>
}
subtitle="日 K · 关键价位 · AI 四维分析(技术 / 基本面 / 财务 / 消息面)"
right={
<div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={onSelect} />
{symbol && (
<button
onClick={() => setShowHistory(v => !v)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-border text-secondary text-xs hover:text-foreground hover:bg-elevated transition-colors"
>
<HistoryIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
}
/>
<div className="px-8 py-6 space-y-6 max-w-7xl">
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="w-72">
<StockFinancialSearch onSelect={onSelect} />
</div>
{symbol && (
<>
<button
onClick={() => setPreviewSymbol(symbol)}
title="查看个股日 K 详情"
className="group flex items-center gap-2 text-sm rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-elevated transition-colors"
>
<span className="text-foreground font-medium group-hover:text-sky-300 transition-colors">{name || symbol}</span>
<span className="text-[10px] font-mono text-muted">{symbol}</span>
<ExternalLink className="h-3 w-3 text-muted opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
<button
onClick={handleAnalyze}
disabled={checking}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-sky-500/25 to-blue-500/15 border border-sky-400/30 text-sky-300 text-xs font-medium hover:from-sky-500/35 hover:to-blue-500/25 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
AI
</button>
<button
onClick={() => toast('点位提醒功能开发中,敬请期待', 'error')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-border/40 bg-elevated/40 text-muted text-xs font-medium hover:border-border/70 hover:text-secondary transition-all"
title="当价格触及关键价位时提醒(开发中)"
>
<Bell className="h-3.5 w-3.5" />
<span className="rounded-full bg-amber-400/15 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-amber-400">
</span>
</button>
</>
)}
</div>
{/* 主体 */}
{!symbol ? (
<EmptyState
icon={LineChart}
title="选择一只股票开始分析"
hint="搜索代码或名称,查看日 K 与关键价位,并可让 AI 进行技术面 / 基本面 / 财务面 / 消息面四维综合分析。"
/>
) : showHistory ? (
<HistoryList symbol={symbol} />
) : (
<StockAnalysisBoard symbol={symbol} />
)}
</div>
{/* 二次确认:已有历史报告 */}
{confirmReport && (
<ConfirmModal
report={confirmReport}
onView={() => { openHistoryReport(confirmReport.id); setConfirmReport(null) }}
onRedo={async () => { setConfirmReport(null); await doAnalysis() }}
onClose={() => setConfirmReport(null)}
/>
)}
{/* 个股日 K 详情对话框(点击名称/代码打开) */}
<StockPreviewDialog
symbol={previewSymbol}
name={previewSymbol === symbol ? name : undefined}
triggerInfo={null}
onClose={() => setPreviewSymbol(null)}
/>
</>
)
}
// ===== 分析看板:日 K + 关键价位 =====
function StockAnalysisBoard({ symbol }: { symbol: string }) {
const kline = useQuery({
queryKey: ['kline', symbol, ''],
queryFn: () => api.klineDaily(symbol, 250),
enabled: !!symbol,
staleTime: 60_000,
})
const levelsQ = useQuery({
queryKey: QK.stockLevels(symbol),
queryFn: () => api.stockAnalysisLevels(symbol, 250),
enabled: !!symbol,
staleTime: 60_000,
})
if (kline.isLoading) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
}
const rows = kline.data?.rows ?? []
if (rows.length === 0) {
return <EmptyState icon={LineChart} title="暂无日 K 数据" hint="该标的尚未同步日 K,请先在数据页或自选页同步。" />
}
const levels = (levelsQ.data?.levels ?? {}) as Record<LevelType, PriceLevel[]>
// 涨跌色:最后一根 K 线收 vs 前一根收(无前日则按开收判断)
const last = rows[rows.length - 1]
const prev = rows[rows.length - 2]
const curClose = levelsQ.data?.close
const isUp = prev ? (last.close >= prev.close) : (last.close >= last.open)
return (
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
<div className="px-4 py-3 border-b border-border/40">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<LineChart className="h-4 w-4 text-sky-400 shrink-0" />
<span className="text-sm font-medium text-foreground"></span>
</div>
<div className="flex items-baseline gap-2 shrink-0">
<span className="text-[10px] text-muted">{rows.length} </span>
<span className="text-[10px] text-muted/60">·</span>
<span className="text-[10px] text-muted"></span>
<span className={`text-base font-mono font-bold ${isUp ? 'text-bull' : 'text-bear'}`}>
{curClose?.toFixed(2) ?? '—'}
</span>
</div>
</div>
</div>
<div className="p-3">
<AnalysisKChart
rows={rows}
levels={levels}
series={levelsQ.data?.series}
seriesDates={levelsQ.data?.dates}
defaultLevelTypes={['sr', 'pivot', 'keltner_s']}
height={480}
/>
</div>
</div>
)
}
// ===== 历史报告列表 =====
function HistoryList({ symbol }: { symbol: string }) {
const { reports, loaded } = useHistoryReports()
const mine = reports.filter(r => r.symbol === symbol)
if (!loaded) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
}
if (mine.length === 0) {
return <EmptyState icon={HistoryIcon} title="暂无历史报告" hint={`还没有 ${symbol} 的个股分析报告,点击「AI 个股分析」生成第一份。`} />
}
return (
<div className="space-y-2">
{mine.map(r => (
<div key={r.id} className="rounded-card border border-border/60 bg-surface/40 p-3 hover:border-border transition-colors">
<div className="flex items-center justify-between gap-3">
<button onClick={() => openHistoryReport(r.id)} className="flex-1 text-left min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-secondary">{fmtRelative(r.created_at)}</span>
{r.close && <span className="text-[10px] font-mono text-muted"> {r.close.toFixed(2)}</span>}
{r.focus && <span className="text-[10px] text-sky-300/70 truncate">: {r.focus}</span>}
</div>
<div className="mt-1 text-xs text-muted truncate">{r.summary || '点击查看完整报告'}</div>
</button>
<button
onClick={() => { deleteReport(r.id); toast('已删除', 'success') }}
className="shrink-0 text-[10px] text-muted hover:text-danger transition-colors px-2 py-1"
>
</button>
</div>
</div>
))}
</div>
)
}
// ===== 二次确认弹窗 =====
function ConfirmModal({ report, onView, onRedo, onClose }: {
report: { id: string; created_at: string; focus: string }
onView: () => void
onRedo: () => void
onClose: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
<div
className="w-full max-w-sm bg-surface border border-border rounded-2xl p-5 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center gap-2 mb-2">
<HistoryIcon className="h-4 w-4 text-sky-400" />
<span className="text-sm font-medium text-foreground"></span>
</div>
<p className="text-xs text-secondary leading-relaxed mb-1">
<span className="text-foreground">{fmtRelative(report.created_at)}</span>
</p>
{report.focus && <p className="text-xs text-muted mb-1">: {report.focus}</p>}
<p className="text-xs text-muted mb-4">,</p>
<div className="flex gap-2">
<button onClick={onView}
className="flex-1 h-8 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
</button>
<button onClick={onRedo}
className="flex-1 h-8 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs text-sky-300 hover:from-sky-500/30 transition-all">
</button>
</div>
</div>
</div>
)
}
function fmtRelative(iso: string): string {
try {
const t = new Date(iso).getTime()
const diff = Date.now() - t
if (diff < 60_000) return '刚刚'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
return new Date(iso).toLocaleDateString('zh-CN')
} catch { return iso }
}
-165
View File
@@ -1,165 +0,0 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Shield, Lock, ArrowRight, AlertCircle, Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { Logo } from '@/components/Logo'
const BRAND = '#8B5CF6'
export function Verify() {
const navigate = useNavigate()
const [uuid, setUuid] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['access-auth-status'],
queryFn: () => api.accessAuthStatus(),
})
// 已验证 或 未启用门控,直接进首页
useEffect(() => {
if (status && (!status.enabled || status.verified)) {
navigate('/', { replace: true })
}
}, [status, navigate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const value = uuid.trim()
if (!value) {
setError('请输入访问密钥')
return
}
setLoading(true)
setError('')
try {
const res = await api.verifyAccessUuid(value)
if (res.valid && res.token) {
localStorage.setItem('access_token', res.token)
localStorage.setItem('access_role', res.role || 'user')
if (res.role === 'admin') {
navigate('/admin/uuids', { replace: true })
} else {
navigate('/', { replace: true })
}
} else {
setError('访问密钥无效,请检查后重试')
}
} catch (err: any) {
setError(err?.message || '验证失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<div className="relative min-h-screen bg-base overflow-hidden flex flex-col">
{/* 背景光晕 */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-40 -left-40 h-[28rem] w-[28rem] rounded-full blur-[120px] opacity-20"
style={{ background: `radial-gradient(circle, ${BRAND}, transparent 70%)` }}
/>
<div
className="absolute -bottom-40 -right-32 h-[26rem] w-[26rem] rounded-full blur-[120px] opacity-15"
style={{ background: 'radial-gradient(circle, hsl(var(--accent)), transparent 70%)' }}
/>
<div
className="absolute inset-0 opacity-[0.025]"
style={{
backgroundImage:
'linear-gradient(hsl(var(--fg-primary)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--fg-primary)) 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
</div>
{/* 顶栏 */}
<header className="relative z-10 flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2.5 text-foreground">
<Logo
size={24}
className="shrink-0"
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
/>
<span className="text-sm font-semibold tracking-tight">Stock Panel</span>
</div>
</header>
{/* 验证卡片 */}
<main className="relative z-10 flex-1 flex items-center justify-center px-6 py-10">
{statusLoading ? (
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-accent" />
</div>
) : (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-md"
>
<div className="rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6">
<div className="flex flex-col items-center text-center">
<div
className="rounded-2xl p-4 border border-border"
style={{ background: `linear-gradient(135deg, ${BRAND}22, transparent)` }}
>
<Shield className="h-8 w-8" style={{ color: BRAND }} />
</div>
<h1 className="mt-5 text-2xl font-bold text-foreground tracking-tight">
访
</h1>
<p className="mt-2 text-sm text-secondary leading-relaxed">
访,访 UUID
</p>
</div>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
<Lock className="h-4 w-4" />
</div>
<input
type="text"
autoComplete="off"
placeholder="输入管理员令牌或访问 UUID"
value={uuid}
onChange={(e) => {
setUuid(e.target.value)
if (error) setError('')
}}
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full inline-flex items-center justify-center gap-2 px-5 h-11 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 hover:shadow-accent/30 disabled:opacity-60 transition-all"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
{loading ? '验证中…' : '进入系统'}
</button>
</form>
</div>
</motion.div>
)}
</main>
</div>
)
}
+97 -9
View File
@@ -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'
@@ -16,6 +16,7 @@ import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives'
import { getSignals, signalCls, getSortValue, UNSORTABLE_KEYS } from '@/lib/stock-table'
import { resolveCandleConfig } from '@/lib/list-columns'
import { useQuoteStatus } from '@/lib/useSharedQueries'
import {
type ColumnConfig,
BUILTIN_COLUMNS,
@@ -295,6 +296,27 @@ function StockSearchBox({
)
}
// ===== 实时监控圆点 =====
// 自选页 symbol 列代码后的小圆点, 标识该标的正在被实时行情监控 (Free/低档按自选监控模式)。
// 视觉: 内圈实心点 + 外圈 animate-ping 扩散晕, 语义=「在线/活动」。
// 配色用 accent (电光蓝) 而非绿/红: 项目设计规范规定红绿仅用于价格/K线,
// UI 状态用 accent, 避免与 A 股涨跌色混淆。
// 全市场模式 (Starter+) 不显示 —— 全部都在监控, 标记无信息量。
function RealtimeDot({ title = '实时监控中' }: { title?: string }) {
return (
<span
title={title}
className="relative inline-flex h-2 w-2 shrink-0"
aria-label={title}
>
{/* 外圈: 扩散晕 (ping 动画) */}
<span className="absolute inline-flex h-full w-full rounded-full bg-accent/60 animate-ping motion-reduce:hidden" />
{/* 内圈: 实心点 + 微辉光 */}
<span className="relative inline-flex rounded-full h-2 w-2 bg-accent shadow-[0_0_5px_rgba(61,214,140,0.6)]" />
</span>
)
}
// ===== 卡片组件 =====
function StockCard({
@@ -309,6 +331,7 @@ function StockCard({
extCols,
expandedCells,
onToggleExpand,
isMonitored,
}: {
r: any
candleRows: KlineRow[]
@@ -321,6 +344,7 @@ function StockCard({
extCols: ColumnConfig[]
expandedCells: Set<string>
onToggleExpand: (key: string) => void
isMonitored?: boolean
}) {
const board = boardTag(r.symbol)
const price = r.rt_price ?? r.close
@@ -394,6 +418,7 @@ function StockCard({
{r.consecutive_limit_ups === 1 ? '首板' : `${r.consecutive_limit_ups}`}
</span>
)}
{isMonitored && <span className="ml-auto"><RealtimeDot /></span>}
</div>
{/* 第二行: 大价格 + 涨跌幅胶囊 */}
@@ -591,6 +616,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: () => {
@@ -610,6 +647,20 @@ export function Watchlist() {
const allSymbols = list.data?.symbols?.map(s => s.symbol) ?? []
const rows = enriched.data?.rows ?? []
// 实时监控圆点: 仅 Free/低档 "按自选股实时监控" 模式 (mode === 'watchlist') 下显示;
// Starter+ 全市场模式 (mode === 'full_market') 全部标的都在监控, 标圆点无意义, 故不显示。
// 后端 Free 档实际只监控自选页前 N 个 (N = watchlist_symbol_count), 顺序与 allSymbols 一致。
const quoteStatus = useQuoteStatus()
const realtimeRunning = quoteStatus.data?.running ?? false
const realtimeMode = quoteStatus.data?.mode
const watchlistMonitoredCount = quoteStatus.data?.watchlist_symbol_count ?? 0
const showRealtimeDot = realtimeRunning && realtimeMode === 'watchlist'
// 真正被监控的标的集合 (自选列表前 watchlistMonitoredCount 个)
const monitoredSymbols = useMemo(
() => showRealtimeDot ? new Set(allSymbols.slice(0, watchlistMonitoredCount)) : new Set<string>(),
[showRealtimeDot, allSymbols, watchlistMonitoredCount],
)
// ===== 筛选 =====
const [filterOpen, setFilterOpen] = useState(false)
const [filters, setFilters] = useState<Record<string, { min?: string; max?: string; text?: string }>>({})
@@ -717,11 +768,34 @@ export function Watchlist() {
[visibleColumns]
)
// 被过滤掉的个股数 (筛选/板块过滤导致的隐藏)
const hiddenCount = Math.max(0, allSymbols.length - sortedRows.length)
return (
<div className="flex flex-col h-full">
<PageHeader
title="自选股"
subtitle={`${sortedRows.length}/${allSymbols.length}`}
titleExtra={
<span className="inline-flex items-center gap-1.5">
{/* 计数胶囊: 显示数/总数, mono 字体突出数字 */}
<span className="inline-flex items-baseline gap-0.5 px-2 py-0.5 rounded-md bg-elevated/70 text-[11px]">
<span className="font-mono font-semibold text-secondary tabular-nums">{sortedRows.length}</span>
<span className="text-muted/50">/</span>
<span className="font-mono text-muted tabular-nums">{allSymbols.length}</span>
<span className="text-muted/60 ml-0.5"></span>
</span>
{/* 过滤提示: 仅在有隐藏时出现, 柔和橙色融入整体 */}
{hiddenCount > 0 && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-warning/12 text-warning/90 border border-warning/25 whitespace-nowrap"
title={`当前有 ${hiddenCount} 只被筛选条件隐藏,清除筛选可查看全部`}
>
<Filter className="h-2.5 w-2.5" />
{hiddenCount}
</span>
)}
</span>
}
right={
<div className="flex items-center gap-2">
{/* 筛选 / 搜索 */}
@@ -934,6 +1008,7 @@ export function Watchlist() {
{board.label}
</span>
) : null}
{monitoredSymbols.has(r.symbol) && <span className="ml-2"><RealtimeDot /></span>}
</button>
{/* 删除入口:默认减号图标,二次确认时替换为确定按钮 */}
<div className="ml-auto pl-1 shrink-0">
@@ -953,13 +1028,25 @@ export function Watchlist() {
</button>
</div>
) : (
<button
onClick={() => setConfirmRemove(r.symbol)}
className="p-0.5 text-muted hover:text-danger transition-colors duration-150 ease-smooth"
aria-label="移除"
>
<Minus className="h-3.5 w-3.5" />
</button>
<div className="flex items-center gap-1">
<button
onClick={() => setConfirmRemove(r.symbol)}
className="p-0.5 text-muted hover:text-danger transition-colors duration-150 ease-smooth"
aria-label="移除"
title="移除"
>
<Minus className="h-3.5 w-3.5" />
</button>
<button
onClick={() => moveToTop.mutate(r.symbol)}
disabled={moveToTop.isPending || allSymbols[0] === r.symbol}
className="p-0.5 text-muted hover:text-accent transition-colors duration-150 ease-smooth disabled:opacity-30 disabled:hover:text-muted"
aria-label="移到顶部"
title="移到顶部"
>
<ChevronsUp className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
@@ -1032,6 +1119,7 @@ export function Watchlist() {
extCols={visibleExtCols}
expandedCells={expandedCells}
onToggleExpand={handleToggleExpand}
isMonitored={monitoredSymbols.has(r.symbol)}
/>
))}
</div>
@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap } from 'lucide-react'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus } from 'lucide-react'
import {
api,
type StrategyBacktestResult,
@@ -19,6 +19,7 @@ import { SignalPicker } from '@/components/screener/SignalPicker'
import { startBacktest, stopBacktest, tryReconnect, useBacktestTask } from '@/lib/backtestTask'
import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries'
import { EmptyState } from '@/components/EmptyState'
import { WarmupBadge } from '@/components/WarmupBadge'
import { DatePicker } from '@/components/DatePicker'
import { StrategyNavChart } from './charts/StrategyNavChart'
import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
@@ -154,6 +155,7 @@ const buildDefaultOverrides = (detail: StrategyDetail) => ({
exit_signals: detail.exit_signals.map(toSignalId),
scoring: { ...detail.scoring },
stop_loss: detail.stop_loss,
take_profit: detail.take_profit,
trailing_stop: detail.trailing_stop,
trailing_take_profit_activate: detail.trailing_take_profit_activate,
trailing_take_profit_drawdown: detail.trailing_take_profit_drawdown,
@@ -192,6 +194,7 @@ function ExitReasonBadge({ reason }: { reason: string }) {
const config: Record<string, { label: string; cls: string }> = {
signal: { label: '信号', cls: 'bg-accent/10 text-accent border-accent/30' },
stop_loss: { label: '止损', cls: 'bg-red-500/10 text-red-400 border-red-500/30' },
take_profit: { label: '止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
trailing_stop: { label: '移损', cls: 'bg-orange-500/10 text-orange-400 border-orange-500/30' },
trailing_take_profit: { label: '回撤止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
max_hold: { label: '超期', cls: 'bg-amber-400/10 text-amber-400 border-amber-400/30' },
@@ -517,6 +520,12 @@ function StockPoolPicker({ value, onChange }: { value: string; onChange: (value:
staleTime: 30_000,
})
const results = search.data?.results ?? []
// 自选列表 — 供「从自选导入」一键填入回测范围
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: () => api.watchlistList(),
staleTime: 30_000,
})
useEffect(() => {
if (results.length === 0) return
@@ -545,43 +554,84 @@ function StockPoolPicker({ value, onChange }: { value: string; onChange: (value:
setOpen(false)
}
const removeSymbol = (symbol: string) => setSymbols(symbols.filter(s => s !== symbol))
// 一键导入自选: 合并去重, 顺带回填股票名
const importFromWatchlist = () => {
const entries = watchlist.data?.symbols ?? []
if (entries.length === 0) return
setSymbolNames(prev => {
const next = { ...prev }
entries.forEach(e => { if (e.name) next[e.symbol] = e.name })
return next
})
setSymbols([...symbols, ...entries.map(e => e.symbol)])
}
const watchlistCount = watchlist.data?.symbols?.length ?? 0
return (
<div className="space-y-2" ref={ref}>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => { if (query.trim()) setOpen(true) }}
placeholder="搜索股票名称/代码添加股票池"
className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none"
/>
{open && results.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-card border border-border bg-base shadow-xl">
{results.map(r => {
const added = symbols.includes(r.symbol)
return (
<button
key={r.symbol}
type="button"
disabled={added}
onClick={() => addSymbol(r.symbol, r.name)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${added ? 'cursor-default text-muted' : 'text-foreground hover:bg-elevated'}`}
>
<span className="w-[78px] shrink-0 font-mono">{r.symbol}</span>
<span className="min-w-0 flex-1 truncate text-secondary">{r.name}</span>
<Plus className={`h-3.5 w-3.5 ${added ? 'opacity-30' : 'text-accent'}`} />
</button>
)
})}
</div>
)}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => { if (query.trim()) setOpen(true) }}
placeholder="搜索股票名称/代码添加股票池"
className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none"
/>
{open && results.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-card border border-border bg-base shadow-xl">
{results.map(r => {
const added = symbols.includes(r.symbol)
return (
<button
key={r.symbol}
type="button"
disabled={added}
onClick={() => addSymbol(r.symbol, r.name)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${added ? 'cursor-default text-muted' : 'text-foreground hover:bg-elevated'}`}
>
<span className="w-[78px] shrink-0 font-mono">{r.symbol}</span>
<span className="min-w-0 flex-1 truncate text-secondary">{r.name}</span>
<Plus className={`h-3.5 w-3.5 ${added ? 'opacity-30' : 'text-accent'}`} />
</button>
)
})}
</div>
)}
</div>
{/* 操作按钮 — 紧贴输入框右侧 */}
<div className="flex shrink-0 items-center gap-1.5">
{/* 当前范围 — 有范围显示个数, 无范围显示全市场 */}
<span className={`whitespace-nowrap text-[11px] font-medium ${symbols.length === 0 ? 'text-amber-400' : 'text-accent'}`}>
{symbols.length === 0 ? '全市场' : `${symbols.length}`}
</span>
<button
type="button"
onClick={importFromWatchlist}
disabled={watchlist.isLoading || watchlistCount === 0}
className="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
title="把自选列表的个股加入回测范围"
>
<ListPlus className="h-3 w-3" />
{watchlist.isLoading ? '加载…' : watchlistCount === 0 ? '自选空' : `导入自选(${watchlistCount})`}
</button>
<button
type="button"
onClick={() => setSymbols([])}
disabled={symbols.length === 0}
className="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-danger/50 hover:text-danger disabled:cursor-not-allowed disabled:opacity-50"
title="清空回测范围"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{symbols.length === 0 ? (
<span className="text-[11px] font-medium text-amber-400"> = </span>
<span className="text-[11px] text-muted"></span>
) : symbols.map(symbol => {
const name = symbolNames[symbol]
return (
@@ -611,6 +661,7 @@ export function StrategyBacktest() {
const [entryFill, setEntryFill] = useState<'close_t' | 'open_t+1'>(saved?.entryFill ?? saved?.matching ?? 'open_t+1')
const [exitFill, setExitFill] = useState<'close_t' | 'open_t+1'>(saved?.exitFill ?? saved?.matching ?? 'close_t')
const [fees, setFees] = useState(saved?.fees ?? '2')
const [slippage, setSlippage] = useState(saved?.slippage ?? '5')
const [maxPositions, setMaxPositions] = useState(saved?.maxPositions ?? '10')
const [maxExposure, setMaxExposure] = useState(saved?.maxExposure ?? '100')
const [initialCapital, setInitialCapital] = useState(saved?.initialCapital ?? '1000000')
@@ -716,6 +767,7 @@ export function StrategyBacktest() {
entryFill,
exitFill,
fees,
slippage,
maxPositions,
maxExposure,
initialCapital,
@@ -740,6 +792,7 @@ export function StrategyBacktest() {
entry_fill: entryFill,
exit_fill: exitFill,
fees_pct: Number(fees) / 10000,
slippage_bps: Number(slippage),
max_positions: Number(maxPositions),
max_exposure_pct: Number(maxExposure) / 100,
initial_capital: Number(initialCapital),
@@ -899,6 +952,7 @@ export function StrategyBacktest() {
const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min)
const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max)
const stopLossPct = overrides.stop_loss == null ? '' : String(Math.abs(Number(overrides.stop_loss)) * 100)
const takeProfitPct = overrides.take_profit == null ? '' : String(Math.abs(Number(overrides.take_profit)) * 100)
const trailingStopPct = overrides.trailing_stop == null ? '' : String(Math.abs(Number(overrides.trailing_stop)) * 100)
const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100)
const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_drawdown)) * 100)
@@ -942,6 +996,7 @@ export function StrategyBacktest() {
`卖点 ${exitSignals.length}`,
scoreFilterSummary,
stopLossPct !== '' ? `止损 ${stopLossPct}%` : '止损未设',
takeProfitPct !== '' ? `止盈 ${takeProfitPct}%` : '止盈未设',
trailingStopPct !== '' ? `移损 ${trailingStopPct}%` : '移损未设',
trailingTakeProfitActivatePct !== '' && trailingTakeProfitDrawdownPct !== '' ? `回撤 ${trailingTakeProfitActivatePct}-${trailingTakeProfitDrawdownPct}` : '回撤未设',
maxHoldDaysValue !== '' ? `最长 ${maxHoldDaysValue}` : '不限持仓',
@@ -1090,7 +1145,10 @@ export function StrategyBacktest() {
<div className="rounded-btn border border-border bg-surface p-2.5">
<div className="flex items-center justify-between gap-2">
<div className="text-xs font-medium text-foreground"></div>
<div className="flex items-center gap-1.5">
<div className="text-xs font-medium text-foreground"></div>
<WarmupBadge />
</div>
<span className="shrink-0 rounded-full border border-accent/25 bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">
{rangeTitle}
</span>
@@ -1241,6 +1299,10 @@ export function StrategyBacktest() {
<label className="text-xs font-medium text-secondary block mb-1.5">()</label>
<input type="number" value={fees} onChange={e => setFees(e.target.value)} className={INPUT_CLS} />
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">()</label>
<input type="number" min={0} value={slippage} onChange={e => setSlippage(e.target.value)} className={INPUT_CLS} />
</div>
</div>
)}
{simMode === 'position' && (
@@ -1481,6 +1543,9 @@ export function StrategyBacktest() {
{result.strategy_info.stop_loss != null && (
<span className="text-[10px] text-secondary"> {fmtPct(result.strategy_info.stop_loss)}</span>
)}
{result.strategy_info.take_profit != null && (
<span className="text-[10px] text-secondary"> {fmtPct(result.strategy_info.take_profit)}</span>
)}
{result.strategy_info.trailing_stop != null && (
<span className="text-[10px] text-secondary"> {fmtPct(result.strategy_info.trailing_stop)}</span>
)}
@@ -1869,7 +1934,7 @@ export function StrategyBacktest() {
</div>
{settingsTab === 'range' && (
<ConfigSection title="回测范围" hint={<span className="font-medium text-amber-400"> = </span>}>
<ConfigSection title="回测范围">
<StockPoolPicker value={symbols} onChange={setSymbols} />
<div className="text-[11px] leading-5 text-muted"></div>
</ConfigSection>
@@ -2091,6 +2156,21 @@ export function StrategyBacktest() {
className={INPUT_CLS}
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">(%)</span>
<input
type="number"
value={takeProfitPct}
min={1}
max={500}
step={0.5}
onChange={e => {
const n = numOrNull(e.target.value)
updateOverride('take_profit', n == null ? null : clamp(Math.abs(n), 1, 500) / 100)
}}
className={INPUT_CLS}
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">(%)</span>
<input
@@ -1,7 +1,6 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { FactorBacktestResult } from '@/lib/api'
import { useChartTheme } from '@/lib/chartTheme'
const GROUP_COLORS = [
'#6366f1', // Q1 indigo
@@ -21,7 +20,6 @@ interface Props {
}
export function FactorGroupNavChart({ result }: Props) {
const chartTheme = useChartTheme()
const option = useMemo(() => {
if (!result.group_nav.length) return null
@@ -60,12 +58,12 @@ export function FactorGroupNavChart({ result }: Props) {
grid: { left: 56, right: 16, top: 12, bottom: 28 },
tooltip: {
trigger: 'axis',
backgroundColor: chartTheme.tooltipBg,
borderColor: chartTheme.tooltipBorder,
textStyle: { color: chartTheme.tooltipText, fontSize: 12 },
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(148,163,184,0.2)',
textStyle: { color: '#e2e8f0', fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:${chartTheme.tooltipTitle};margin-bottom:4px">${date}</div>`
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
html += `<div style="display:flex;justify-content:space-between;gap:16px">
@@ -82,22 +80,22 @@ export function FactorGroupNavChart({ result }: Props) {
xAxis: {
type: 'category',
data: dates,
axisLabel: { color: chartTheme.text, fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: chartTheme.axisLine } },
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: '#334155' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
scale: true,
axisLabel: { color: chartTheme.text, fontSize: 10 },
splitLine: { lineStyle: { color: chartTheme.splitLine } },
axisLabel: { color: '#64748b', fontSize: 10 },
splitLine: { lineStyle: { color: '#1e293b' } },
axisLine: { show: false },
},
series,
} as any
}, [result.group_nav, result.long_short_nav, result.run_id])
const chartRef = useECharts(option, [result.run_id, chartTheme])
const chartRef = useECharts(option, [result.run_id])
// 图例
const groupCols = result.group_nav.length > 0
@@ -1,14 +1,12 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { FactorBacktestResult } from '@/lib/api'
import { useChartTheme } from '@/lib/chartTheme'
interface Props {
result: FactorBacktestResult
}
export function FactorICChart({ result }: Props) {
const chartTheme = useChartTheme()
const option = useMemo(() => {
if (!result.ic_series.length) return null
@@ -28,12 +26,12 @@ export function FactorICChart({ result }: Props) {
grid: { left: 50, right: 16, top: 16, bottom: 28 },
tooltip: {
trigger: 'axis',
backgroundColor: chartTheme.tooltipBg,
borderColor: chartTheme.tooltipBorder,
textStyle: { color: chartTheme.tooltipText, fontSize: 12 },
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(148,163,184,0.2)',
textStyle: { color: '#e2e8f0', fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:${chartTheme.tooltipTitle};margin-bottom:4px">${date}</div>`
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
html += `<div style="display:flex;justify-content:space-between;gap:16px">
@@ -47,14 +45,14 @@ export function FactorICChart({ result }: Props) {
xAxis: {
type: 'category',
data: dates,
axisLabel: { color: chartTheme.text, fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: chartTheme.axisLine } },
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: '#334155' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
axisLabel: { color: chartTheme.text, fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
splitLine: { lineStyle: { color: chartTheme.splitLine } },
axisLabel: { color: '#64748b', fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
splitLine: { lineStyle: { color: '#1e293b' } },
axisLine: { show: false },
},
series: [
@@ -84,7 +82,7 @@ export function FactorICChart({ result }: Props) {
} as any
}, [result.ic_series])
const chartRef = useECharts(option, [result.run_id, chartTheme])
const chartRef = useECharts(option, [result.run_id])
return <div ref={chartRef} className="h-[200px]" />
}
@@ -1,7 +1,6 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { EChartsOption } from 'echarts'
import { useChartTheme } from '@/lib/chartTheme'
interface DistBin {
range: string
@@ -14,7 +13,6 @@ interface DistBin {
* (绿),
*/
export function ReturnDistributionChart({ distribution }: { distribution: DistBin[] }) {
const chartTheme = useChartTheme()
const option = useMemo<EChartsOption>(() => {
const cats = distribution.map(d => d.range)
const vals = distribution.map(d => d.count)
@@ -41,13 +39,13 @@ export function ReturnDistributionChart({ distribution }: { distribution: DistBi
xAxis: {
type: 'category',
data: cats,
axisLabel: { color: chartTheme.text, fontSize: 10, rotate: 45, interval: 1 },
axisLine: { lineStyle: { color: chartTheme.axisLine } },
axisLabel: { color: '#a1a1aa', fontSize: 10, rotate: 45, interval: 1 },
axisLine: { lineStyle: { color: '#3f3f46' } },
},
yAxis: {
type: 'value',
axisLabel: { color: chartTheme.text, fontSize: 10 },
splitLine: { lineStyle: { color: chartTheme.splitLine } },
axisLabel: { color: '#a1a1aa', fontSize: 10 },
splitLine: { lineStyle: { color: '#27272a' } },
},
series: [
{
@@ -59,7 +57,7 @@ export function ReturnDistributionChart({ distribution }: { distribution: DistBi
}
}, [distribution])
const chartRef = useECharts(option, [distribution, chartTheme])
const chartRef = useECharts(option, [distribution])
return <div ref={chartRef} className="h-48 w-full" />
}
@@ -1,14 +1,12 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { StrategyBacktestResult } from '@/lib/api'
import { useChartTheme } from '@/lib/chartTheme'
interface Props {
result: StrategyBacktestResult
}
export function StrategyNavChart({ result }: Props) {
const chartTheme = useChartTheme()
const option = useMemo(() => {
if (!result.equity_curve.length) return null
@@ -30,7 +28,7 @@ export function StrategyNavChart({ result }: Props) {
animation: false,
axisPointer: {
link: [{ xAxisIndex: 'all' }],
label: { backgroundColor: chartTheme.tooltipBg, color: chartTheme.tooltipText },
label: { backgroundColor: '#334155' },
},
grid: [
{ left: 64, right: hasBenchmark ? 64 : 16, top: 14, bottom: '40%' },
@@ -41,14 +39,14 @@ export function StrategyNavChart({ result }: Props) {
type: 'category', data: dates, gridIndex: 0,
axisLabel: { show: false }, axisTick: { show: false },
axisPointer: { show: true, type: 'line' },
axisLine: { lineStyle: { color: chartTheme.axisLine } },
axisLine: { lineStyle: { color: '#334155' } },
},
{
type: 'category', data: dates, gridIndex: 1,
axisLabel: { color: chartTheme.text, fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
axisTick: { show: false },
axisPointer: { show: true, type: 'line' },
axisLine: { lineStyle: { color: chartTheme.axisLine } },
axisLine: { lineStyle: { color: '#334155' } },
},
],
yAxis: [
@@ -56,13 +54,13 @@ export function StrategyNavChart({ result }: Props) {
type: 'value', gridIndex: 0,
scale: true,
name: hasBenchmark ? '上证点位' : '策略资金',
nameTextStyle: { color: chartTheme.text, fontSize: 10, padding: [0, 0, 4, 0] },
nameTextStyle: { color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
axisLabel: {
color: chartTheme.text,
color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b',
fontSize: 10,
formatter: hasBenchmark ? ((v: number) => v.toFixed(0)) : axisMoneyFmt,
},
splitLine: { lineStyle: { color: chartTheme.splitLine } },
splitLine: { lineStyle: { color: '#1e293b' } },
axisLine: { show: false },
},
{
@@ -70,10 +68,10 @@ export function StrategyNavChart({ result }: Props) {
position: 'right',
scale: true,
name: hasBenchmark ? '策略资金' : '',
nameTextStyle: { color: chartTheme.text, fontSize: 10, padding: [0, 0, 4, 0] },
nameTextStyle: { color: '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
axisLabel: {
show: hasBenchmark,
color: chartTheme.text,
color: '#64748b',
fontSize: 10,
formatter: axisMoneyFmt,
},
@@ -85,10 +83,10 @@ export function StrategyNavChart({ result }: Props) {
position: 'right',
max: 0,
axisLabel: {
color: chartTheme.text, fontSize: 10,
color: '#64748b', fontSize: 10,
formatter: (v: number) => `${v.toFixed(1)}%`,
},
splitLine: { lineStyle: { color: chartTheme.splitLine } },
splitLine: { lineStyle: { color: '#1e293b' } },
axisLine: { show: false },
},
],
@@ -107,22 +105,22 @@ export function StrategyNavChart({ result }: Props) {
filterMode: 'filter',
height: 16,
bottom: 10,
borderColor: chartTheme.axisLine,
backgroundColor: chartTheme.dataZoomBg,
fillerColor: chartTheme.dataZoomFiller,
handleStyle: { color: chartTheme.dataZoomHandle, borderColor: chartTheme.axisLine },
textStyle: { color: chartTheme.text, fontSize: 10 },
borderColor: 'rgba(148,163,184,0.18)',
backgroundColor: 'rgba(15,23,42,0.55)',
fillerColor: 'rgba(59,130,246,0.18)',
handleStyle: { color: '#64748b', borderColor: '#94a3b8' },
textStyle: { color: '#64748b', fontSize: 10 },
brushSelect: false,
},
],
tooltip: {
trigger: 'axis',
backgroundColor: chartTheme.tooltipBg,
borderColor: chartTheme.tooltipBorder,
textStyle: { color: chartTheme.tooltipText, fontSize: 12 },
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(148,163,184,0.2)',
textStyle: { color: '#e2e8f0', fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:${chartTheme.tooltipTitle};margin-bottom:4px">${date}</div>`
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
const isDrawdown = p.seriesName === '回撤'
@@ -184,7 +182,7 @@ export function StrategyNavChart({ result }: Props) {
} as any
}, [result.equity_curve, result.drawdown_curve, result.benchmark_curve, result.run_id])
const chartRef = useECharts(option, [result.run_id, chartTheme])
const chartRef = useECharts(option, [result.run_id])
return (
<div>
+367 -127
View File
@@ -1,13 +1,36 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield } from 'lucide-react'
import {
Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield,
Shuffle, Plug, Zap, Settings2, ExternalLink, Trash2,
Terminal,
} from 'lucide-react'
import { useSettings } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { api, type SettingsState } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
const PRESETS: { label: string; url: string; model: string; website: string; websiteLabel: string; description: string }[] = [
{ label: 'DeepSeek', url: 'https://api.deepseek.com/v1', model: 'deepseek-chat', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' },
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
// 统一的输入框样式(与项目其他设置页一致)
const INPUT_CLS =
'w-full h-9 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow'
const CODEX_PROVIDER = 'codex_cli'
const OPENAI_PROVIDER = 'openai_compat'
const CUSTOM_CODEX_MODEL = '__custom__'
const CODEX_COMMAND = 'codex'
const CODEX_MODEL_OPTIONS = [
{ label: 'Codex 默认(推荐)', value: '', hint: '使用当前 Codex CLI 支持的默认模型' },
{ label: 'gpt-5.5', value: 'gpt-5.5', hint: '高能力模型' },
{ label: 'gpt-5', value: 'gpt-5', hint: '通用模型' },
]
const PRESETS: { label: string; provider?: string; url: string; model: string; codexCommand?: string; website: string; websiteLabel: string; description: string; partner?: boolean; promo?: string }[] = [
{ label: 'DeepSeek', url: 'https://api.deepseek.com', model: 'deepseek-v4-pro', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' },
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-3.6plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
{ label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-5.2', website: 'https://open.bigmodel.cn/', websiteLabel: 'open.bigmodel.cn', description: '智谱 AI 官方 OpenAI 兼容接口。' },
{ label: 'Kimi', url: 'https://api.moonshot.cn/v1', model: 'kimi-k2.6', website: 'https://platform.moonshot.cn/', websiteLabel: 'platform.moonshot.cn', description: '月之暗面 Moonshot 官方 OpenAI 兼容接口,支持超长上下文。' },
{ label: 'Codex CLI', provider: CODEX_PROVIDER, url: '', model: '', codexCommand: CODEX_COMMAND, website: 'https://developers.openai.com/codex/noninteractive', websiteLabel: 'codex exec', description: '调用本机 Codex CLI 的 codex exec, 适合已登录 ChatGPT/Codex 的本地环境。' },
{ label: '炸鸡中转站', url: 'https://code.alysc.top/v1', model: 'gpt-5.5', website: 'https://code.alysc.top/sign-up?aff=1afk', websiteLabel: 'code.alysc.top', description: 'OpenAI 兼容中转服务,适合直接使用国际模型。', partner: true, promo: '通过链接邀请注册赠送免费额度 · 国际模型最低0.01倍率' },
]
export function SettingsAIPanel() {
@@ -15,183 +38,400 @@ export function SettingsAIPanel() {
const settings = useSettings()
const s = settings.data
const [provider, setProvider] = useState('openai_compat')
const [provider, setProvider] = useState(OPENAI_PROVIDER)
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [model, setModel] = useState('')
const [tokenBudget, setTokenBudget] = useState(5_000_000)
const [codexCustomModel, setCodexCustomModel] = useState(false)
const [codexCommand, setCodexCommand] = useState(CODEX_COMMAND)
const [customUa, setCustomUa] = useState(false)
const [userAgent, setUserAgent] = useState('')
const [showKey, setShowKey] = useState(false)
const [saved, setSaved] = useState(false)
// 测试
const [confirmClear, setConfirmClear] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null)
const isCodexProvider = provider === CODEX_PROVIDER
const savedCodexProvider = s?.ai_provider === CODEX_PROVIDER
const configured = s?.ai_configured ?? (savedCodexProvider ? !!(s?.ai_codex_command ?? CODEX_COMMAND) : s?.has_ai_key)
const selectedPreset = PRESETS.find(p => (p.provider ?? OPENAI_PROVIDER) === provider && (isCodexProvider ? p.codexCommand === codexCommand : p.url === baseUrl))
const codexModelSelectValue = codexCustomModel ? CUSTOM_CODEX_MODEL : model
const canSave = isCodexProvider ? true : !!baseUrl.trim() && !!model.trim()
useEffect(() => {
if (!s) return
setProvider(s.ai_provider ?? 'openai_compat')
setProvider(s.ai_provider ?? OPENAI_PROVIDER)
setBaseUrl(s.ai_base_url ?? '')
setModel(s.ai_model ?? '')
setTokenBudget(s.ai_daily_token_budget ?? 500_000)
setCodexCustomModel(!!s.ai_model && !CODEX_MODEL_OPTIONS.some(o => o.value === s.ai_model))
setCodexCommand(s.ai_codex_command ?? CODEX_COMMAND)
const ua = s.ai_user_agent ?? ''
setCustomUa(!!ua)
setUserAgent(ua)
}, [s])
const payload = () => ({
provider,
base_url: baseUrl,
api_key: apiKey || undefined,
model,
codex_command: isCodexProvider ? CODEX_COMMAND : codexCommand,
user_agent: customUa ? userAgent : '',
})
const save = useMutation({
mutationFn: () => api.saveAiSettings({
provider, base_url: baseUrl, api_key: apiKey || undefined, model, daily_token_budget: tokenBudget,
}),
onSuccess: () => {
setSaved(true); setApiKey(''); qc.invalidateQueries({ queryKey: QK.settings })
mutationFn: () => api.saveAiSettings(payload()),
onSuccess: (result) => {
setSaved(true)
setApiKey('')
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
...prev,
ai_provider: result.ai_provider ?? provider,
ai_base_url: baseUrl,
ai_model: result.ai_model ?? model,
ai_codex_command: result.ai_codex_command ?? (isCodexProvider ? CODEX_COMMAND : codexCommand),
ai_configured: result.ai_configured ?? (isCodexProvider ? true : (apiKey ? true : prev.ai_configured)),
...(apiKey ? {
has_ai_key: true,
ai_api_key_masked: `${apiKey.slice(0, 4)}......${apiKey.slice(-4)}`,
} : {}),
} : prev)
qc.invalidateQueries({ queryKey: QK.settings })
setTimeout(() => setSaved(false), 2000)
},
})
const handleTest = async () => {
setTesting(true); setTestResult(null)
try {
// 先保存当前配置(不保存 Key 仅用于测试时临时存)
if (apiKey) await api.saveAiSettings({ provider, base_url: baseUrl, api_key: apiKey, model, daily_token_budget: tokenBudget })
const r = await api.strategyAiTest()
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · 模型: ${r.model}${r.usage ? ` · 消耗 ${r.usage.prompt + r.usage.completion} tokens` : ''}` : (r.error ?? '未知错误') })
} catch (e: any) {
setTestResult({ ok: false, msg: String(e?.message ?? '测试失败') })
} finally { setTesting(false) }
const clear = useMutation({
mutationFn: () => api.clearAiSettings(),
onSuccess: () => {
setConfirmClear(false)
setProvider(OPENAI_PROVIDER)
setBaseUrl('')
setApiKey('')
setModel('')
setCodexCustomModel(false)
setCodexCommand(CODEX_COMMAND)
setTestResult(null)
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
...prev,
ai_provider: OPENAI_PROVIDER,
ai_base_url: '',
ai_model: '',
ai_codex_command: CODEX_COMMAND,
has_ai_key: false,
ai_configured: false,
ai_api_key_masked: '',
} : prev)
qc.invalidateQueries({ queryKey: QK.settings })
},
})
const genRandomUa = () => {
const major = 128 + Math.floor(Math.random() * 8)
const platforms = [
'Windows NT 10.0; Win64; x64',
'Macintosh; Intel Mac OS X 10_15_7',
'X11; Linux x86_64',
]
const pf = platforms[Math.floor(Math.random() * platforms.length)]
setUserAgent(`Mozilla/5.0 (${pf}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`)
}
const handlePreset = (p: typeof PRESETS[number]) => {
setBaseUrl(p.url); setModel(p.model)
setProvider(p.provider ?? OPENAI_PROVIDER)
setBaseUrl(p.url)
setModel(p.model)
setCodexCustomModel(false)
if (p.codexCommand) setCodexCommand(CODEX_COMMAND)
}
const configured = s?.has_ai_key
const selectedPreset = PRESETS.find(p => p.url === baseUrl)
const handleTest = async () => {
setTesting(true)
setTestResult(null)
try {
if (canSave) await api.saveAiSettings(payload())
const r = await api.strategyAiTest()
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · ${r.model ?? provider}` : (r.error ?? '未知错误') })
} catch (e: any) {
setTestResult({ ok: false, msg: String(e?.message ?? '测试失败') })
} finally {
setTesting(false)
}
}
return (
<div className="max-w-2xl space-y-5">
{/* ===== 状态横幅 ===== */}
<div className={`rounded-2xl border px-5 py-4 flex items-center gap-4 ${configured ? 'border-emerald-400/20 bg-emerald-400/[0.04]' : 'border-amber-400/20 bg-amber-400/[0.04]'}`}>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${configured ? 'bg-emerald-400/10 text-emerald-400' : 'bg-amber-400/10 text-amber-400'}`}>
{configured ? <Wifi className="h-5 w-5" /> : <WifiOff className="h-5 w-5" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-foreground">{configured ? 'AI 已连接' : 'AI 未配置'}</div>
<div className="text-xs text-muted mt-0.5">
{configured ? `${s?.ai_model} · ${s?.ai_api_key_masked}` : '配置 API Key 后即可使用 AI 策略定制'}
</div>
</div>
{configured && (
<div className="space-y-5 max-w-2xl">
<Card icon={Plug} title="连接状态" right={
configured && (
<button onClick={handleTest} disabled={testing}
className="h-8 px-3 rounded-lg border border-border/50 text-xs text-secondary hover:text-foreground hover:border-accent/30 transition-all flex items-center gap-1.5 shrink-0">
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated hover:bg-elevated/80 text-xs text-secondary transition-colors duration-150 ease-smooth disabled:opacity-50">
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wifi className="h-3 w-3" />}
{testing ? '测试中' : '测试'}
</button>
)}
</div>
{/* 测试结果 */}
{testResult && (
<div className={`rounded-xl border px-4 py-3 text-xs flex items-center gap-2.5 ${testResult.ok ? 'border-emerald-400/20 bg-emerald-400/[0.04] text-emerald-400' : 'border-danger/20 bg-danger/[0.04] text-danger'}`}>
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${testResult.ok ? 'bg-emerald-400' : 'bg-danger'}`} />
{testResult.msg}
)
}>
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${configured ? 'bg-emerald-400/10 text-emerald-400' : 'bg-amber-400/10 text-amber-400'}`}>
{configured ? <Wifi className="h-4.5 w-4.5" /> : <WifiOff className="h-4.5 w-4.5" />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{configured ? 'AI 已连接' : 'AI 未配置'}</div>
<div className="text-xs text-muted mt-0.5 truncate">
{configured
? (savedCodexProvider
? `${s?.ai_codex_command ?? CODEX_COMMAND} · ${s?.ai_model || '默认模型'}`
: `${s?.ai_model} · ${s?.ai_api_key_masked}`)
: (isCodexProvider ? '使用本机 codex exec, 此处无需填写 API Key。' : '配置 API Key 后即可使用 AI 功能。')}
</div>
</div>
</div>
)}
{testResult && (
<div className={`mt-3 rounded-btn border px-3 py-2 text-xs flex items-center gap-2 ${testResult.ok ? 'border-emerald-400/20 bg-emerald-400/[0.04] text-emerald-400' : 'border-danger/20 bg-danger/[0.04] text-danger'}`}>
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${testResult.ok ? 'bg-emerald-400' : 'bg-danger'}`} />
{testResult.msg}
</div>
)}
</Card>
{/* ===== 快速预设 ===== */}
<div className="space-y-2">
<div className="text-[10px] text-muted/50 uppercase tracking-wider"></div>
<div className="flex flex-wrap items-start gap-x-4 gap-y-3">
<Card icon={Zap} title="快速预设">
<div className="flex flex-wrap items-start gap-2">
{PRESETS.map(p => (
<button key={p.label} onClick={() => handlePreset(p)}
className={`rounded-lg border px-3 py-2 text-left transition-all ${baseUrl === p.url ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-surface text-secondary hover:border-accent/30'}`}>
className={`rounded-lg border px-3 py-2 text-left transition-all ${selectedPreset?.label === p.label ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-base text-secondary hover:border-accent/30'}`}>
<div className="flex items-center gap-1.5 text-xs font-medium">
<span>{p.label}</span>
{p.provider === CODEX_PROVIDER && <Terminal className="h-3 w-3" />}
{p.partner && <span className="rounded-full border border-orange-400/30 bg-orange-400/10 px-1.5 py-px text-[9px] text-orange-400"></span>}
</div>
</button>
))}
</div>
{selectedPreset && (
<div className="rounded-lg border border-border/30 bg-surface/30 px-3 py-2 text-[10px] leading-relaxed">
<div className="mt-3 rounded-btn border border-border/30 bg-base/30 px-3 py-2 text-[11px] leading-relaxed">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-secondary">{selectedPreset.description}</span>
{selectedPreset.promo && <span className="text-amber-400">{selectedPreset.promo}</span>}
</div>
<a href={selectedPreset.website} target="_blank" rel="noreferrer"
className="mt-1 inline-flex text-muted hover:text-accent transition-colors">
{selectedPreset.websiteLabel}
className="mt-1 inline-flex items-center gap-1 text-muted hover:text-accent transition-colors">
{selectedPreset.websiteLabel}
<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
</Card>
<Card
icon={Settings2}
title="自定义配置"
right={
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted/60" title={isCodexProvider ? 'Use local Codex CLI via codex exec' : 'Use OpenAI-compatible Chat Completions API'}>
<span className="rounded-full border border-border/40 bg-base/50 px-1.5 py-px font-mono">{isCodexProvider ? 'codex exec' : 'Chat Completions'}</span>
{isCodexProvider ? 'CLI' : '接口'}
</span>
}
>
<div className="space-y-4">
{isCodexProvider ? (
<div className="grid grid-cols-2 gap-4">
<Field label="CLI 命令" hint="固定使用默认 codex 命令, 由后端自动解析本机 Codex Desktop/CLI, 不支持自定义可执行路径。">
<div className={`${INPUT_CLS} flex items-center text-muted/80 select-none`} aria-label="Codex CLI command">
{CODEX_COMMAND}
</div>
</Field>
<Field
label="模型(可选)"
hint={codexCustomModel
? '留空则使用 Codex 默认模型'
: CODEX_MODEL_OPTIONS.find(o => o.value === model)?.hint}
>
<select
value={codexModelSelectValue}
onChange={e => {
const value = e.target.value
if (value === CUSTOM_CODEX_MODEL) {
setCodexCustomModel(true)
if (CODEX_MODEL_OPTIONS.some(o => o.value === model)) setModel('')
} else {
setCodexCustomModel(false)
setModel(value)
}
}}
className={INPUT_CLS}
>
{CODEX_MODEL_OPTIONS.map(option => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
<option value={CUSTOM_CODEX_MODEL}></option>
</select>
{codexCustomModel && (
<input
type="text"
value={model}
onChange={e => setModel(e.target.value)}
placeholder="例如 gpt-5.5"
className={`${INPUT_CLS} mt-2`}
/>
)}
</Field>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<Field label="API 地址">
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} placeholder="https://code.alysc.top" className={INPUT_CLS} />
</Field>
<Field label="模型">
<input type="text" value={model} onChange={e => setModel(e.target.value)} placeholder="gpt-5.5" className={INPUT_CLS} />
</Field>
</div>
<Field label="API Key">
<div className="flex gap-2">
<div className="flex-1 relative">
<input type={showKey ? 'text' : 'password'} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={configured ? `${s?.ai_api_key_masked} · 留空不修改` : 'sk-...'} className={`${INPUT_CLS} pr-9`} />
<button onClick={() => setShowKey(v => !v)} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted" tabIndex={-1} aria-label={showKey ? '隐藏' : '显示'}>
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<button onClick={handleTest} disabled={testing || !apiKey} className="h-9 px-3 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 disabled:opacity-40 transition-all flex items-center gap-1.5 shrink-0">
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wifi className="h-3 w-3" />}
</button>
</div>
</Field>
<div className="border-t border-border/20" />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Field label="自定义 User-Agent" inline>
<Toggle checked={customUa} onChange={() => setCustomUa(v => !v)} />
</Field>
</div>
{customUa && (
<div className="flex gap-2">
<input type="text" value={userAgent} onChange={e => setUserAgent(e.target.value)} placeholder="粘贴浏览器 User-Agent" className={`${INPUT_CLS} flex-1`} />
<button type="button" onClick={genRandomUa} title="随机生成浏览器 User-Agent" className="h-9 px-2.5 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 transition-all flex items-center gap-1.5 shrink-0">
<Shuffle className="h-3 w-3" />
</button>
</div>
)}
</div>
</>
)}
</div>
</Card>
<div className="rounded-card border border-amber-400/20 bg-amber-400/[0.04] px-4 py-3 flex items-start gap-3">
<Shield className="h-4 w-4 text-amber-400/70 mt-0.5 shrink-0" />
<div className="text-[11px] text-amber-400/70 leading-relaxed">
{isCodexProvider
? 'Codex CLI 模式会复用本机已登录的 Codex 账户, 个股、财务、复盘等分析上下文会发送给 OpenAI/Codex。保存即表示确认仅在本机或可信内网使用。'
: 'API Key 仅保存在本机项目文件中, 不会上传到任何服务器。请妥善保管。'}
</div>
</div>
{/* ===== 自定义配置卡片 ===== */}
<div className="rounded-2xl border border-border/30 bg-surface/30 overflow-hidden">
<div className="px-5 py-3 border-b border-border/20">
<span className="text-xs font-medium text-foreground/70"></span>
</div>
<div className="px-5 py-4 space-y-4">
{/* API 地址 + 模型 同行 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider">API </label>
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)}
placeholder="https://code.alysc.top"
className="w-full h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider"></label>
<input type="text" value={model} onChange={e => setModel(e.target.value)}
placeholder="gpt-5.5"
className="w-full h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
</div>
</div>
<div className="flex gap-2">
<button onClick={() => save.mutate()} disabled={save.isPending || !canSave} className="flex-1 h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all">
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{save.isPending ? '保存中...' : saved ? '已保存' : '保存配置'}
</button>
{configured && (
<button onClick={() => setConfirmClear(true)} disabled={clear.isPending} className="h-10 px-4 rounded-xl bg-elevated text-secondary hover:text-danger text-sm flex items-center justify-center gap-1.5 hover:bg-elevated/80 disabled:opacity-50 transition-all shrink-0" title="Clear AI provider configuration">
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
{/* API Key */}
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider">API Key</label>
<div className="flex gap-2">
<div className="flex-1 relative">
<input
type={showKey ? 'text' : 'password'}
value={apiKey} onChange={e => setApiKey(e.target.value)}
placeholder={configured ? `${s?.ai_api_key_masked} · 留空不修改` : 'sk-...'}
className="w-full h-8 px-2.5 pr-8 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
<button onClick={() => setShowKey(v => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted">
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<button onClick={handleTest} disabled={testing || !apiKey}
className="h-8 px-3 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 disabled:opacity-40 transition-all flex items-center gap-1.5 shrink-0">
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wifi className="h-3 w-3" />}
{confirmClear && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmClear(false)} />
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
<h3 className="text-sm font-medium text-foreground mb-2"> AI </h3>
<p className="text-xs text-secondary mb-5 leading-relaxed">
providerAPI KeyAPI Codex CLI
</p>
<div className="flex items-center justify-end gap-2">
<button onClick={() => setConfirmClear(false)} className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors">
</button>
<button onClick={() => clear.mutate()} disabled={clear.isPending} className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50">
{clear.isPending ? '清空中...' : '确认'}
</button>
</div>
</div>
{/* Token 预算 */}
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider"> Token </label>
<div className="flex items-center gap-3">
<input type="number" value={tokenBudget} onChange={e => setTokenBudget(Math.max(10000, Number(e.target.value) || 0))}
min={10000} step={100000}
className="w-44 h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
<span className="text-[10px] text-muted"> AI </span>
</div>
</div>
</div>
</div>
{/* ===== 安全提示 ===== */}
<div className="rounded-2xl border border-amber-400/20 bg-amber-400/[0.04] px-5 py-3.5 flex items-start gap-3">
<Shield className="h-4 w-4 text-amber-400/70 mt-0.5 shrink-0" />
<div className="text-[10px] text-amber-400/70 leading-relaxed">
API Key
</div>
</div>
{/* ===== 保存 ===== */}
<button onClick={() => save.mutate()} disabled={save.isPending || !baseUrl || !model}
className="w-full h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all">
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{save.isPending ? '保存中...' : saved ? '已保存' : '保存配置'}
</button>
)}
</div>
)
}
// ===== 通用卡片(与 Keys 页风格统一) =====
interface CardProps {
icon: React.ComponentType<{ className?: string }>
title: string
right?: React.ReactNode
children: React.ReactNode
}
function Card({ icon: Icon, title, right, children }: CardProps) {
return (
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2.5">
<Icon className="h-4 w-4 text-secondary" />
<h2 className="text-sm font-medium text-foreground">{title}</h2>
</div>
{right}
</div>
{children}
</section>
)
}
// ===== 表单字段(统一 label + 输入框样式) =====
function Field({ label, hint, inline, children }: {
label: string
hint?: string
inline?: boolean
children: React.ReactNode
}) {
if (inline) {
return (
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[10px] text-muted/50 uppercase tracking-wider">{label}</div>
{hint && <div className="text-[10px] text-muted mt-0.5">{hint}</div>}
</div>
{children}
</div>
)
}
return (
<div className="space-y-1.5">
<div className="text-[10px] text-muted/50 uppercase tracking-wider">{label}</div>
{children}
{hint && <div className="text-[10px] text-muted">{hint}</div>}
</div>
)
}
// ===== 开关 =====
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
return (
<button
type="button"
onClick={onChange}
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${checked ? 'bg-accent' : 'bg-elevated'}`}
aria-pressed={checked}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
)
}
+14 -10
View File
@@ -73,16 +73,16 @@ export function SettingsKeysPanel() {
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.3fr] gap-6 max-w-5xl">
{/* ========== 左列: Key 配置 ========== */}
<div className="space-y-6">
<Card icon={Key} title="数据源 API Key">
<Card icon={Key} title="TickFlow API Key">
<p className="text-sm text-secondary leading-relaxed mb-4">
{' '}
<a
href="https://tickflow.org/auth/register"
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline inline-flex items-baseline gap-0.5"
>
tickflow.org
<ExternalLink className="h-3 w-3 self-center" />
</a>{' '}
API Key ,,
@@ -108,7 +108,7 @@ export function SettingsKeysPanel() {
) : (
<>
<AlertCircle className="h-4 w-4 text-muted shrink-0" />
<span className="text-sm font-medium text-muted"> · Free </span>
<span className="text-sm font-medium text-muted"></span>
</>
)}
</div>
@@ -136,7 +136,7 @@ export function SettingsKeysPanel() {
<div className="relative">
<input
type={revealing ? 'text' : 'password'}
placeholder={mode === 'none' ? '粘贴数据源 API Key' : '粘贴新 Key 替换当前'}
placeholder={mode === 'none' ? '粘贴 TickFlow API Key' : '粘贴新 Key 替换当前'}
value={keyInput}
onChange={(e) => { setKeyInput(e.target.value); if (saved) setSaved(false) }}
className="w-full px-3 py-2 pr-9 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth"
@@ -197,7 +197,7 @@ export function SettingsKeysPanel() {
<div className="mt-3 text-xs text-bear flex items-center gap-1.5">
<CheckCircle2 className="h-3 w-3" />
{save.data.tier_label}
{save.data.mode === 'free' && '(免费档 · 历史日K)'}
{save.data.mode === 'free' && '(免费档 · 历史日K + 自选实时监控)'}
</div>
)}
</Card>
@@ -318,7 +318,7 @@ export function SettingsKeysPanel() {
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
<h3 className="text-sm font-medium text-foreground mb-2"> API Key</h3>
<p className="text-xs text-secondary mb-5">
退(K), Key
退 None (K), Key
</p>
<div className="flex items-center justify-end gap-2">
<button
@@ -384,18 +384,22 @@ function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
return (
<div key={t} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={s.dotStyle} />
<span className="font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t === 'none' ? '' : t}</span>
<span className="font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t === 'none' ? 'None' : t}</span>
<span className="text-secondary">{s.desc}</span>
</div>
)
})}
</div>
<div className="mb-3 rounded-btn border border-warning/30 bg-warning/10 px-2.5 py-1.5 text-[11px] font-medium text-warning">
</div>
{/* 检测说明 */}
<div className="text-secondary space-y-1.5">
<div className="font-medium text-foreground"></div>
<p> Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为( Key);K但无复权因子则判为Free; Starter/Pro/Expert</p>
<p className="text-muted">(K), Key,</p>
<p> Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为None( Key);K但无复权因子则判为Free; Starter/Pro/Expert</p>
<p className="text-muted">None Free (K), Key,</p>
</div>
</motion.div>
</>
@@ -39,7 +39,8 @@ const BUILTIN_PAGES: NavEntry[] = [
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
{ id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true },
{ id: '/financials', label: '财务', type: 'builtin', visible: true },
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
{ id: '/trading', label: '交易', type: 'builtin', visible: true },
{ id: '/monitor', label: '监控中心', type: 'builtin', visible: true },
+230 -41
View File
@@ -1,13 +1,14 @@
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,
Wifi,
BarChart3,
Flame,
Zap,
Bell,
Webhook,
ChevronDown,
} from 'lucide-react'
import {
usePreferences,
@@ -46,13 +47,15 @@ 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
const systemNotify = prefs?.system_notify_enabled ?? false
const hasDepth = !!caps?.capabilities?.['depth5.batch']
// 新建监控规则时是否默认勾选飞书推送 (全局默认值, 单条规则可独立修改)
const webhookDefault = prefs?.webhook_enabled_default ?? false
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const isRunning = quoteStatus?.running ?? false
@@ -60,6 +63,27 @@ 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 feishuWebhookUrl = prefs?.feishu_webhook_url ?? ''
const feishuWebhookSecret = prefs?.feishu_webhook_secret ?? ''
const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl)
const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret)
const [feishuError, setFeishuError] = useState('')
// 飞书渠道配置区展开态 (推送通知卡片内)
const [channelOpen, setChannelOpen] = useState(false)
useEffect(() => {
setFeishuDraft(feishuWebhookUrl)
setFeishuSecretDraft(feishuWebhookSecret)
}, [feishuWebhookUrl, feishuWebhookSecret])
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<string, unknown>) => {
try {
@@ -95,11 +119,31 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const toggleSystemNotify = useCallback(async (enabled: boolean) => {
await api.updateSystemNotify(enabled)
const toggleWebhookDefault = useCallback(async (enabled: boolean) => {
await api.updateWebhookDefault(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const saveFeishuWebhook = useMutation({
mutationFn: ({ url, secret }: { url: string; secret: string }) => api.updateFeishuWebhook(url, secret),
onSuccess: () => {
setFeishuError('')
toast('飞书 Webhook 已保存', 'success')
qc.invalidateQueries({ queryKey: QK.preferences })
},
onError: (err: any) => setFeishuError(String(err?.message ?? '保存失败')),
})
const FEISHU_PREFIX = 'https://open.feishu.cn/open-apis/bot/v2/hook/'
const submitFeishu = useCallback(() => {
const url = feishuDraft.trim()
const secret = feishuSecretDraft.trim()
if (url && !url.startsWith(FEISHU_PREFIX)) {
setFeishuError('地址需以 ' + FEISHU_PREFIX + ' 开头')
return
}
saveFeishuWebhook.mutate({ url, secret })
}, [feishuDraft, feishuSecretDraft, saveFeishuWebhook])
const runFix = useMutation({
mutationFn: () => api.runLimitLadderFix(),
onSuccess: (data) => {
@@ -110,6 +154,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 +181,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
}
}, [highlight])
// Free 档位 — 显示升级提示
if (isFreeTier) {
if (isNoneTier) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
@@ -135,8 +190,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
</div>
<h2 className="text-lg font-medium text-foreground mb-2"></h2>
<p className="text-sm text-secondary max-w-md mb-6">
Starter
Free None 使 free-api K1-2
</p>
<a
href="/settings?tab=account"
@@ -167,10 +221,12 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
<div className="flex items-center justify-between gap-4 py-1">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted"></div>
<div className="text-[11px] text-muted">
{isFreeTier ? '每轮拉取自选股实时行情的时间间隔' : '每轮拉取全市场行情的时间间隔'}
</div>
</div>
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
{interval < 1 ? interval.toFixed(1) : interval.toFixed(0)}s
{intervalDraft < 1 ? intervalDraft.toFixed(1) : intervalDraft.toFixed(0)}s
</span>
</div>
<div className="flex items-center gap-3 mt-2">
@@ -179,18 +235,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"
/>
<span className="text-[10px] text-muted shrink-0">
{minInterval}s {maxInterval}s
{intervalDraft !== interval ? '2秒后保存' : `${minInterval}s — ${maxInterval}s`}
</span>
</div>
</div>
</Card>
{/* 页面刷新 */}
{isFreeTier && (
<Card icon={Activity} title="自选股实时">
<div className="mb-3 rounded-btn border border-accent/25 bg-accent/10 px-3 py-2 text-xs font-medium leading-snug text-accent">
Free 5 6
</div>
{watchlistSymbols.length > 0 ? (
<div className="space-y-1.5">
{watchlistSymbols.map(symbol => {
const name = watchlistNameBySymbol.get(symbol)
return (
<div key={symbol} className="flex items-center justify-between rounded-btn bg-base/50 border border-border px-2 py-1.5">
<div className="min-w-0 flex items-baseline gap-1.5">
<span className="text-xs font-mono text-foreground">{symbol}</span>
{name && <span className="truncate text-[11px] text-secondary">{name}</span>}
</div>
<span className="text-[10px] text-muted shrink-0"></span>
</div>
)
})}
</div>
) : (
<div className="rounded-btn border border-border bg-base/40 px-3 py-3 text-xs text-muted">
Free
</div>
)}
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-[10px] text-muted"> {watchlistSymbols.length}/5 </span>
<Link
to="/watchlist"
className="px-3 py-1 rounded-btn bg-elevated text-secondary text-xs font-medium hover:text-foreground transition-colors"
>
</Link>
</div>
</Card>
)}
{!isFreeTier && (
<Card icon={Wifi} title="页面实时刷新">
<p className="text-xs text-secondary mb-4">
SSE
@@ -208,7 +300,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
))}
</div>
</Card>
)}
{!isFreeTier && (
<Card icon={BarChart3} title="左侧菜单指数">
<p className="text-xs text-secondary mb-4">
@@ -233,33 +327,12 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
/>
</div>
</Card>
)}
</div>
{/* ========== 右列 ========== */}
<div className="space-y-6">
{/* 策略监控已迁移至监控中心 */}
<Card icon={Shield} title="策略监控">
<p className="text-xs text-secondary mb-3">
,
</p>
<a
href="#/monitor"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/15 text-accent text-xs font-medium hover:bg-accent/25 transition-colors"
>
</a>
<div className="mt-3 pt-3 border-t border-border">
<ToggleRow
icon={Bell}
label="系统通知"
desc="监控告警同时推送到操作系统通知中心(窗口最小化或后台也能收到)"
checked={systemNotify}
onChange={toggleSystemNotify}
/>
</div>
</Card>
{/* 连板梯队降级修正 */}
{/* 连板梯队降级修正 (移至右列顶部) */}
<div
id="depth-fix"
className={`rounded-card transition-all duration-500 ${flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'}`}
@@ -305,6 +378,122 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
)}
</Card>
</div>
{/* ()
; , QMT/ptrade
每个渠道合并成一行: 勾选=, */}
<Card icon={Webhook} title="推送通知">
<p className="text-xs text-secondary mb-3">
,<b className="text-foreground/80"></b>,
</p>
{/* 渠道列表 — 每行一个渠道, 勾选默认 + 点行展开地址配置 */}
<div className="space-y-2">
{/* 飞书 (可用): 勾选默认 + 展开地址配置 */}
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
<div
onClick={() => setChannelOpen(o => !o)}
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
>
<input
type="checkbox"
checked={webhookDefault}
onChange={e => { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }}
onClick={e => e.stopPropagation()}
title="作为新建规则的默认推送渠道"
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] font-medium text-foreground"></span>
<span className="text-[9px] text-muted"></span>
{webhookDefault && (
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent"></span>
)}
<span className={`ml-auto text-[9px] ${feishuWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
{feishuWebhookUrl ? '已配置' : '未配置'}
</span>
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${channelOpen ? 'rotate-180' : ''}`} />
</div>
{/* 飞书地址配置 — 行内展开 */}
{channelOpen && (
<div className="border-t border-border/60 bg-base/30 p-3">
<label className="block space-y-1.5">
<span className="text-[11px] text-muted">Webhook </span>
<input
value={feishuDraft}
onChange={e => setFeishuDraft(e.target.value)}
placeholder={FEISHU_PREFIX + 'xxxxxxxx'}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
/>
</label>
<label className="block mt-2 space-y-1.5">
<span className="text-[11px] text-muted"> ( · )</span>
<input
type="password"
value={feishuSecretDraft}
onChange={e => setFeishuSecretDraft(e.target.value)}
placeholder="机器人未启用签名校验则留空"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
/>
</label>
{feishuError && (
<div className="mt-2 text-[11px] text-danger">{feishuError}</div>
)}
<div className="mt-2 flex items-center gap-2">
<button
onClick={submitFeishu}
disabled={saveFeishuWebhook.isPending || (feishuDraft.trim() === feishuWebhookUrl && feishuSecretDraft.trim() === feishuWebhookSecret)}
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
>
{saveFeishuWebhook.isPending ? '保存中…' : '保存'}
</button>
{feishuWebhookUrl && (
<span className="text-[10px] text-emerald-500"> </span>
)}
</div>
<details className="mt-3 text-[10px] text-muted">
<summary className="cursor-pointer hover:text-secondary"> Webhook ?</summary>
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
<li>, <b></b></li>
<li> <b></b></li>
<li>, Webhook </li>
<li><b></b>,</li>
<li></li>
</ol>
<p className="mt-1.5 pl-4 text-muted/70">
📖 :
<a href="https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
使
</a>
</p>
</details>
</div>
)}
</div>
{/* 占位渠道 — 不可点 */}
{[
{ name: '微信', hint: '公众号/企业微信', status: '开发中' },
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
{ name: 'ptrade', hint: '量化交易终端', status: '待定' },
].map(ch => (
<div
key={ch.name}
className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-2 opacity-60"
>
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">{ch.name}</span>
<span className="text-[9px] text-muted">{ch.hint}</span>
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">{ch.status}</span>
</div>
))}
</div>
</Card>
</div>
</div>
)
+2 -41
View File
@@ -5,8 +5,7 @@
*/
import { useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info, Sun } from 'lucide-react'
import { useTheme, type Theme } from '@/components/ThemeProvider'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -67,21 +66,6 @@ export function SettingsSystemPanel() {
/>
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center gap-2 mb-4">
<Sun className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"></div>
</div>
<ThemeSelect />
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Settings2 className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
@@ -231,7 +215,7 @@ export function SettingsSystemPanel() {
<div className="text-[11px] text-muted truncate"> GitHub Releases </div>
</div>
<a
href="https://github.com/shy3130/stock-panel/releases/latest"
href="https://github.com/shy3130/tickflow-stock-panel/releases/latest"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs
@@ -247,29 +231,6 @@ export function SettingsSystemPanel() {
}
// ===== ThemeSelect =====
function ThemeSelect() {
const { theme, setTheme } = useTheme()
const options: { value: Theme; label: string }[] = [
{ value: 'system', label: '跟随系统' },
{ value: 'light', label: '浅色' },
{ value: 'dark', label: '深色' },
]
return (
<select
value={theme}
onChange={(e) => setTheme(e.target.value as Theme)}
className="h-8 px-2 rounded-btn border border-border bg-base text-xs text-foreground shrink-0"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)
}
// ===== ToggleRow =====
function ToggleRow({
+33 -13
View File
@@ -1,14 +1,11 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { AccessGuard } from './components/AccessGuard'
import { AdminGuard } from './components/AdminGuard'
import { Layout } from './components/Layout'
import { Verify } from './pages/Verify'
import { AdminUuids } from './pages/AdminUuids'
import { Watchlist } from './pages/Watchlist'
import { Screener } from './pages/Screener'
import { Backtest } from './pages/Backtest'
import { Financials } from './pages/Financials'
import { Onboarding } from './pages/Onboarding'
import { Auth } from './pages/Auth'
import { Data } from './pages/Data'
import { Monitor } from './pages/Monitor'
import { Trading } from './pages/Trading'
@@ -17,30 +14,52 @@ import { AnalysisDetail } from './pages/AnalysisDetail'
import { ConceptAnalysis } from './pages/ConceptAnalysis'
import { IndustryAnalysis } from './pages/IndustryAnalysis'
import { StockAnalysis } from './pages/StockAnalysis'
import { Review } from './pages/Review'
import { LimitUpLadder } from './pages/LimitUpLadder'
import { Branding } from './pages/Branding'
import { Settings } from './pages/Settings'
import { Indices } from './pages/Indices'
import { Dev } from './pages/Dev'
import { useSettings } from './lib/useSharedQueries'
import { Logo } from './components/Logo'
// 首次使用守卫 —— 当前已关闭:数据源 Key 已内置,用户无需首次配置。
// 如需重新启用引导页,恢复下方判定逻辑即可
// 首次使用守卫 —— 未完成向导则重定向到 /onboarding
// 只挂在根路由上;/onboarding 本身不被守卫,避免循环重定向
// settings 由 Layout 预取,守卫判定不产生额外请求。
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const settings = useSettings()
// 仅首次加载(本地无缓存)时显示占位。
// 后台重取 (isFetching) 时本地已有上一份缓存可用, 直接放行, 避免切页时整屏 logo 闪烁。
// 防误重定向已由 Onboarding/AI 等处 invalidate 前的 setQueryData 同步缓存兜底。
if (settings.isLoading) {
return (
<div className="min-h-screen bg-base grid place-items-center">
<div className="flex flex-col items-center gap-3 text-muted">
<Logo size={28} className="text-foreground" />
<div className="text-xs"></div>
</div>
</div>
)
}
// 查询出错或字段缺失时不拦截 —— 宁可放行,也不把用户卡在空白页
if (settings.data && settings.data.onboarding_completed === false) {
return <Navigate to="/onboarding" replace />
}
return <>{children}</>
}
export const router = createBrowserRouter([
{ path: '/verify', element: <Verify /> },
{ path: '/onboarding', element: <Onboarding /> },
{ path: '/admin/uuids', element: <AdminGuard><AdminUuids /></AdminGuard> },
{ path: '/login', element: <Auth /> },
{
path: '/',
element: (
<AccessGuard>
<OnboardingGuard>
<Layout />
</OnboardingGuard>
</AccessGuard>
<OnboardingGuard>
<Layout />
</OnboardingGuard>
),
children: [
{ index: true, element: <Dashboard /> },
@@ -50,6 +69,7 @@ export const router = createBrowserRouter([
{ path: 'concept-analysis', element: <ConceptAnalysis /> },
{ path: 'industry-analysis', element: <IndustryAnalysis /> },
{ path: 'stock-analysis', element: <StockAnalysis /> },
{ path: 'review', element: <Review /> },
{ path: 'watchlist', element: <Watchlist /> },
{ path: 'screener', element: <Screener /> },
{ path: 'backtest', element: <Backtest /> },
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/components/alerttoast.tsx","./src/components/candlestickchart.tsx","./src/components/columncustomizer.tsx","./src/components/datepicker.tsx","./src/components/echartscandlestick.tsx","./src/components/echartsintraday.tsx","./src/components/emptystate.tsx","./src/components/endpointtestdialog.tsx","./src/components/extdimensionanalysis.tsx","./src/components/layout.tsx","./src/components/listcolumncustomizer.tsx","./src/components/logo.tsx","./src/components/pageheader.tsx","./src/components/sealedbadge.tsx","./src/components/stockdailykchart.tsx","./src/components/stockinfobar.tsx","./src/components/stockintradaychart.tsx","./src/components/stockpanel.tsx","./src/components/stockpreviewdialog.tsx","./src/components/themeprovider.tsx","./src/components/toast.tsx","./src/components/analysis-shared.tsx","./src/components/data/activejobcard.tsx","./src/components/data/depthconfigcard.tsx","./src/components/data/enrichedrebuildpanel.tsx","./src/components/data/extendhistorypanel.tsx","./src/components/data/minutesyncconfig.tsx","./src/components/data/quoteconfigcard.tsx","./src/components/data/scheduleeditor.tsx","./src/components/data/schemamodal.tsx","./src/components/data/sectiontitle.tsx","./src/components/data/settingsmodal.tsx","./src/components/data/skeleton.tsx","./src/components/data/statcard.tsx","./src/components/ext-data/createextdialog.tsx","./src/components/ext-data/editextdialog.tsx","./src/components/ext-data/extdataapipanel.tsx","./src/components/ext-data/extdatapullpanel.tsx","./src/components/ext-data/extdatastatcard.tsx","./src/components/financials/stockfinancialdetail.tsx","./src/components/financials/stockfinancialsearch.tsx","./src/components/monitor/ruleeditor.tsx","./src/components/screener/screenerfilter.tsx","./src/components/screener/screenertable.tsx","./src/components/screener/signalpicker.tsx","./src/components/screener/strategybuilderdialog.tsx","./src/components/screener/strategycard.tsx","./src/components/screener/strategypooldialog.tsx","./src/components/screener/strategysettingsdialog.tsx","./src/components/screener/strategystoredialog.tsx","./src/components/signals/customsignaldialog.tsx","./src/components/signals/signaltriggeractions.tsx","./src/components/stock-table/minicandlestick.tsx","./src/components/stock-table/stockdatatable.tsx","./src/components/stock-table/primitives.tsx","./src/components/stock-table/usetablesort.ts","./src/lib/analysis-adapter.ts","./src/lib/api.ts","./src/lib/backtesttask.ts","./src/lib/board.ts","./src/lib/capability-labels.tsx","./src/lib/charttheme.ts","./src/lib/cn.ts","./src/lib/colors.ts","./src/lib/format.ts","./src/lib/list-columns.ts","./src/lib/monitorbadge.ts","./src/lib/notificationsound.ts","./src/lib/querykeys.ts","./src/lib/screener-columns.ts","./src/lib/signals.ts","./src/lib/stock-info-fields.ts","./src/lib/stock-table.ts","./src/lib/storage.ts","./src/lib/usefinancials.ts","./src/lib/usequeryconfig.ts","./src/lib/usequotestream.ts","./src/lib/usesharedmutations.ts","./src/lib/usesharedqueries.ts","./src/lib/usestrategypool.ts","./src/lib/watchlist-columns.ts","./src/pages/analysis.tsx","./src/pages/analysisdetail.tsx","./src/pages/backtest.tsx","./src/pages/branding.tsx","./src/pages/conceptanalysis.tsx","./src/pages/dashboard.tsx","./src/pages/data.tsx","./src/pages/dev.tsx","./src/pages/financials.tsx","./src/pages/indices.tsx","./src/pages/industryanalysis.tsx","./src/pages/limitupladder.tsx","./src/pages/monitor.tsx","./src/pages/onboarding.tsx","./src/pages/screener.tsx","./src/pages/settings.tsx","./src/pages/stockanalysis.tsx","./src/pages/trading.tsx","./src/pages/watchlist.tsx","./src/pages/backtest/factorbacktest.tsx","./src/pages/backtest/strategybacktest.tsx","./src/pages/backtest/charts/factorgroupnavchart.tsx","./src/pages/backtest/charts/factoricchart.tsx","./src/pages/backtest/charts/returndistributionchart.tsx","./src/pages/backtest/charts/strategynavchart.tsx","./src/pages/backtest/charts/useecharts.ts","./src/pages/backtest/components/tradeklinemodal.tsx","./src/pages/settings/ai.tsx","./src/pages/settings/customsignals.tsx","./src/pages/settings/extpages.tsx","./src/pages/settings/keys.tsx","./src/pages/settings/menusettings.tsx","./src/pages/settings/monitoring.tsx","./src/pages/settings/system.tsx"],"version":"5.9.3"}