重置项目
This commit is contained in:
@@ -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" />
|
||||
|
||||
Generated
-3041
File diff suppressed because it is too large
Load Diff
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 limits;tierReq → 无权限时显示的档位要求
|
||||
// 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} />
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
操作不可恢复,需重新执行同步才能恢复数据。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '请检查扩展数据是否包含行业/板块相关字段'} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
这会清空已保存的 provider、API Key、API 地址、模型和 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 获取历史日K(当日数据需盘后1-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>
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 +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"}
|
||||
Reference in New Issue
Block a user