初始化工程

This commit is contained in:
2026-07-01 22:07:55 +08:00
parent 9bb6d7a654
commit fc2b0944d2
248 changed files with 65020 additions and 316 deletions
+46
View File
@@ -0,0 +1,46 @@
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}</>
}
+33
View File
@@ -0,0 +1,33 @@
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 />
}
+176
View File
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { Bell, TrendingUp, TrendingDown, X } from 'lucide-react'
import type { AlertEvent } from '@/lib/api'
import { fmtPct, fmtPrice } from '@/lib/format'
import { cn } from '@/lib/cn'
import { playNotificationSound } from '@/lib/notificationSound'
// ===== 全局状态 (模块级, 仿 Toast.tsx 模式) =====
type Item = { id: number; alert: AlertEvent }
let _id = 0
let _queue: Item[] = []
const AUTO_DISMISS = 5000 // 5 秒自动消失
const _listeners: Set<(items: Item[]) => void> = new Set()
/** 从 localStorage 读取配置 */
function getEnabled(): boolean {
try {
const v = localStorage.getItem('alert_toast_enabled')
return v === null ? true : v === '1' // 默认开启
} catch { return true }
}
function getMaxVisible(): number {
try {
const v = parseInt(localStorage.getItem('alert_toast_max') || '', 10)
return v >= 1 && v <= 10 ? v : 3 // 默认 3, 范围 1-10
} catch { return 3 }
}
/** 通知外部配置变更后刷新 (设置页改了配置后调用) */
export function refreshAlertToastConfig() {
_emit()
}
function _emit() { _listeners.forEach(fn => fn([..._queue])) }
/** 推入单条监控告警通知 (兼容入口, 不发声 — 发声由批量入口统一处理) */
export function pushAlertToast(alert: AlertEvent) {
pushAlertToasts([alert])
}
/**
* 批量推入监控告警通知 (一轮 SSE 多只新命中时调用)。
* - 每条都弹 Toast (受 maxVisible 上限, 超出丢最旧)
* - 整批只播放一声通知音, 避免短时连续响多声刷屏
*/
export function pushAlertToasts(alerts: AlertEvent[]) {
if (alerts.length === 0) return
if (!getEnabled()) return // 开关关闭: 不弹
const maxVisible = getMaxVisible()
const newItems = alerts.map(alert => ({ id: ++_id, alert }))
_queue = [..._queue, ...newItems]
// 超出上限: 丢弃最旧的
if (_queue.length > maxVisible) {
_queue = _queue.slice(-maxVisible)
}
_emit()
for (const item of newItems) {
setTimeout(() => dismiss(item.id), AUTO_DISMISS)
}
playNotificationSound() // 整批只响一声
}
/** 手动关闭 */
export function dismiss(id: number) {
_queue = _queue.filter(t => t.id !== id)
_emit()
}
// ===== 配色 =====
const SEVERITY_BAR: Record<string, string> = {
info: 'bg-accent', warn: 'bg-warning', critical: 'bg-danger',
}
const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
strategy: { label: '策略', cls: 'bg-amber-400/15 text-amber-400' },
signal: { label: '信号', cls: 'bg-accent/15 text-accent' },
price: { label: '价格', cls: 'bg-emerald-400/15 text-emerald-400' },
market: { label: '异动', cls: 'bg-purple-500/15 text-purple-400' },
new_entry: { label: '进入', cls: 'bg-emerald-400/15 text-emerald-400' },
dropped: { label: '移出', cls: 'bg-danger/15 text-danger' },
}
// ===== 容器 — 挂在 Layout =====
export function AlertToastContainer() {
const [items, setItems] = useState<Item[]>([])
const navigate = useNavigate()
const sub = useCallback(() => {
_listeners.add(setItems)
return () => { _listeners.delete(setItems) }
}, [])
useEffect(sub, [sub])
// 点击通知 → 跳转监控中心 + 关闭当前通知
const handleClick = (id: number) => {
dismiss(id)
navigate('/monitor')
}
if (!items.length) return null
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 w-[320px] pointer-events-none">
<AnimatePresence>
{items
.filter(item => !(item.alert.source === 'strategy' && !item.alert.symbol))
.map(item => {
const ev = item.alert
const sev = SEVERITY_BAR[ev.severity ?? 'info'] ?? SEVERITY_BAR.info
const badgeKey = (ev.source === 'strategy' && ev.type) ? ev.type : ev.source
const badge = SOURCE_BADGE[badgeKey] ?? { label: badgeKey, cls: 'bg-elevated text-muted' }
const pct = ev.change_pct ?? 0
const isStrategy = ev.source === 'strategy'
const sm = isStrategy ? ev.message?.match(/策略「([^」]+)」/) : null
const sname = sm ? sm[1] : ''
const isNew = ev.type === 'new_entry'
return (
<motion.div
key={item.id}
layout
initial={{ opacity: 0, x: 60, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 60, scale: 0.9 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
onClick={() => handleClick(item.id)}
className="pointer-events-auto relative overflow-hidden rounded-xl border border-border/60 bg-surface/95 backdrop-blur-md shadow-2xl pl-3 pr-2 py-2.5 cursor-pointer hover:border-accent/40 hover:shadow-accent/10 transition-all"
>
{/* 左侧色条 */}
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
{/* 顶行: 分类标签 + 代码/名称 + 涨跌幅 + 关闭 */}
<div className="flex items-center gap-2">
<span className={cn('shrink-0 rounded px-1 py-px text-[9px] font-medium', badge.cls)}>
{badge.label}
</span>
{ev.symbol && <span className="font-mono text-xs font-medium text-foreground shrink-0">{ev.symbol}</span>}
{ev.name && <span className="text-xs text-secondary truncate flex-1">{ev.name}</span>}
{ev.change_pct != null && (
<span className={cn('inline-flex items-center gap-0.5 text-[10px] font-mono font-medium shrink-0', pct >= 0 ? 'text-danger' : 'text-bear')}>
{pct >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
{fmtPct(pct)}
</span>
)}
<button onClick={(e) => { e.stopPropagation(); dismiss(item.id) }} className="shrink-0 p-0.5 rounded text-muted/50 hover:text-foreground hover:bg-elevated transition-colors cursor-pointer">
<X className="h-3 w-3" />
</button>
</div>
{/* 底行: 策略类型走新格式, 其他走旧格式 */}
{isStrategy ? (
<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-'))} />
<span className={cn('text-[11px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
{isNew ? '进入' : '移出'}
</span>
<span className="text-[11px] text-foreground/70"></span>
<span className="text-[11px] font-medium text-amber-400">{sname}</span>
<span className="flex-1" />
{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">
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
{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>
)
})}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,154 @@
import { useEffect, useRef } from 'react'
import {
createChart,
CrosshairMode,
type IChartApi,
type ISeriesApi,
type CandlestickData,
type HistogramData,
} from 'lightweight-charts'
export interface OHLC {
date: string
open: number
high: number
low: number
close: number
volume?: number
}
export function fmtBigNum(v: number): string {
if (v >= 1_000_000_000_000) return `${(v / 1_000_000_000_000).toFixed(2)}万亿`
if (v >= 100_000_000) return `${(v / 100_000_000).toFixed(2)}亿`
if (v >= 10_000) return `${(v / 10_000).toFixed(0)}`
return v.toFixed(0)
}
const THEME = {
background: 'transparent',
textColor: '#A1A1AA',
gridColor: 'rgba(255,255,255,0.04)',
borderColor: '#27272A',
bull: '#F04438',
bear: '#12B76A',
volBull: 'rgba(240,68,56,0.4)',
volBear: 'rgba(18,183,106,0.4)',
}
interface Props {
data: OHLC[]
height?: number
}
export function CandlestickChart({ data, height = 480 }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const candleRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const volRef = useRef<ISeriesApi<'Histogram'> | null>(null)
useEffect(() => {
if (!containerRef.current) return
const el = containerRef.current
const chart = createChart(el, {
width: el.clientWidth,
height,
layout: {
background: { color: THEME.background },
textColor: THEME.textColor,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 11,
},
grid: {
vertLines: { color: THEME.gridColor },
horzLines: { color: THEME.gridColor },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { labelVisible: false },
horzLine: { labelVisible: false },
},
rightPriceScale: { borderColor: THEME.borderColor },
timeScale: {
borderColor: THEME.borderColor,
timeVisible: false,
secondsVisible: false,
},
})
// Candlestick — occupies top 80% via default price scale
const candle = chart.addCandlestickSeries({
upColor: THEME.bull,
downColor: THEME.bear,
borderUpColor: THEME.bull,
borderDownColor: THEME.bear,
wickUpColor: THEME.bull,
wickDownColor: THEME.bear,
lastValueVisible: false,
priceLineVisible: false,
})
// Volume — separate price scale, squeezed to bottom 20%
const volume = chart.addHistogramSeries({
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
lastValueVisible: false,
priceLineVisible: false,
})
chart.priceScale('volume').applyOptions({
scaleMargins: { top: 0.8, bottom: 0 },
})
chartRef.current = chart
candleRef.current = candle
volRef.current = volume
const ro = new ResizeObserver(() => {
chart.applyOptions({ width: el.clientWidth })
})
ro.observe(el)
return () => {
ro.disconnect()
chart.remove()
chartRef.current = null
candleRef.current = null
volRef.current = null
}
}, [height])
useEffect(() => {
if (!chartRef.current || !candleRef.current || !volRef.current || data.length === 0) return
candleRef.current.setData(
data.map(d => ({
time: d.date as any,
open: d.open,
high: d.high,
low: d.low,
close: d.close,
})) as CandlestickData[],
)
volRef.current.setData(
data.map(d => ({
time: d.date as any,
value: d.volume ?? 0,
color: d.close >= d.open ? THEME.volBull : THEME.volBear,
})) as HistogramData[],
)
const ts = chartRef.current.timeScale()
if (data.length > 60) {
const startIdx = data.length - 60
ts.setVisibleRange({
from: data[startIdx].date as any,
to: data[data.length - 1].date as any,
})
} else {
ts.fitContent()
}
}, [data])
return <div ref={containerRef} className="w-full" style={{ height }} />
}
@@ -0,0 +1,24 @@
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { COLUMN_GROUPS, type ColumnConfig } from '@/lib/watchlist-columns'
interface ColumnCustomizerProps {
columns: ColumnConfig[]
onChange: (columns: ColumnConfig[]) => void
open: boolean
onClose: () => void
}
export function ColumnCustomizer({ columns, onChange, open, onClose }: ColumnCustomizerProps) {
return (
<ListColumnCustomizer
columns={columns}
groups={COLUMN_GROUPS}
onChange={onChange}
open={open}
onClose={onClose}
title="自定义列"
builtinSectionLabel="内置列"
extColumnAlign="right"
/>
)
}
+235
View File
@@ -0,0 +1,235 @@
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'
interface DatePickerProps {
value: string // YYYY-MM-DD
onChange: (v: string) => void
min?: string
max?: string
placeholder?: string
className?: string
buttonClassName?: string
align?: 'left' | 'right'
}
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']
function pad(n: number) { return String(n).padStart(2, '0') }
function toDateStr(y: number, m: number, d: number) {
return `${y}-${pad(m + 1)}-${pad(d)}`
}
function todayStr() {
const date = new Date()
return toDateStr(date.getFullYear(), date.getMonth(), date.getDate())
}
function viewDate(value: string, min?: string, max?: string) {
const source = value || max || min || todayStr()
return {
year: Number(source.slice(0, 4)),
month: Number(source.slice(5, 7)) - 1,
}
}
export function DatePicker({
value,
onChange,
min,
max,
placeholder = '选择日期',
className = '',
buttonClassName = '',
align = 'right',
}: DatePickerProps) {
const [open, setOpen] = useState(false)
const [showYearPicker, setShowYearPicker] = useState(false)
const ref = useRef<HTMLDivElement>(null)
// 当前显示的月份
const [viewYear, setViewYear] = useState(() => viewDate(value, min, max).year)
const [viewMonth, setViewMonth] = useState(() => viewDate(value, min, max).month)
// 当 value 外部变化时同步 view
useEffect(() => {
const next = viewDate(value, min, max)
setViewYear(next.year)
setViewMonth(next.month)
}, [value, min, max])
// 点击外部关闭
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const prevMonth = () => {
if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1) }
else setViewMonth(viewMonth - 1)
}
const nextMonth = () => {
if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1) }
else setViewMonth(viewMonth + 1)
}
// 构建日历格子: 周一为第一天
const firstDay = new Date(viewYear, viewMonth, 1).getDay()
const offset = firstDay === 0 ? 6 : firstDay - 1 // 周一=0
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
const prevMonthDays = new Date(viewYear, viewMonth, 0).getDate()
const cells: { day: number; cur: boolean; dateStr: string; disabled: boolean }[] = []
// 上月尾部
for (let i = offset - 1; i >= 0; i--) {
const d = prevMonthDays - i
const m = viewMonth === 0 ? 11 : viewMonth - 1
const y = viewMonth === 0 ? viewYear - 1 : viewYear
const ds = toDateStr(y, m, d)
cells.push({ day: d, cur: false, dateStr: ds, disabled: !!min && ds < min || !!max && ds > max })
}
// 当月
for (let d = 1; d <= daysInMonth; d++) {
const ds = toDateStr(viewYear, viewMonth, d)
cells.push({ day: d, cur: true, dateStr: ds, disabled: !!min && ds < min || !!max && ds > max })
}
// 下月头部 — 补齐到 6 行 × 7 = 42
const remain = 42 - cells.length
for (let d = 1; d <= remain; d++) {
const m = viewMonth === 11 ? 0 : viewMonth + 1
const y = viewMonth === 11 ? viewYear + 1 : viewYear
const ds = toDateStr(y, m, d)
cells.push({ day: d, cur: false, dateStr: ds, disabled: !!min && ds < min || !!max && ds > max })
}
const displayLabel = value || placeholder
const today = todayStr()
return (
<div ref={ref} className={`relative inline-flex ${className}`}>
{/* 触发按钮 */}
<button
type="button"
onClick={() => setOpen(!open)}
className={`inline-flex items-center gap-1.5 h-7 px-2.5 rounded-input border border-border
bg-elevated hover:border-accent/50 text-xs text-foreground num
focus:outline-none focus:border-accent/60 transition-colors duration-150 cursor-pointer ${buttonClassName}`}
>
<Calendar className="h-3.5 w-3.5 text-accent" />
<span className={value ? undefined : 'text-muted'}>{displayLabel}</span>
</button>
{/* 弹出日历 */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.97 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
className={`absolute ${align === 'left' ? 'left-0' : 'right-0'} top-full mt-1.5 z-50 w-[260px] rounded-card border border-border
bg-surface shadow-[0_8px_30px_rgba(0,0,0,0.4)] p-3`}
>
{/* 月份导航 */}
<div className="flex items-center justify-between mb-2">
<button
type="button"
onClick={showYearPicker ? () => setViewYear(viewYear - 12) : prevMonth}
className="p-1 rounded-btn hover:bg-elevated text-secondary hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setShowYearPicker(v => !v)}
className="text-sm font-medium text-foreground num hover:text-accent transition-colors cursor-pointer"
>
{showYearPicker
? `${viewYear - 5} - ${viewYear + 6}`
: `${viewYear}${viewMonth + 1}`
}
</button>
<button
type="button"
onClick={showYearPicker ? () => setViewYear(viewYear + 12) : nextMonth}
className="p-1 rounded-btn hover:bg-elevated text-secondary hover:text-foreground transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{showYearPicker ? (
/* 年份选择网格 */
<div className="grid grid-cols-4 gap-1">
{Array.from({ length: 12 }, (_, i) => viewYear - 5 + i).map(y => {
const isSelected = y === Number(value.slice(0, 4))
const isThisYear = y === new Date().getFullYear()
return (
<button
key={y}
type="button"
onClick={() => {
setViewYear(y)
setShowYearPicker(false)
}}
className={`h-8 text-xs rounded-btn transition-colors duration-100
${isSelected ? 'bg-accent text-white font-bold' : ''}
${isThisYear && !isSelected ? 'border border-accent/40' : ''}
${!isSelected ? 'hover:bg-elevated cursor-pointer text-foreground' : ''}
`}
>
{y}
</button>
)
})}
</div>
) : (
<>
{/* 星期头 */}
<div className="grid grid-cols-7 text-center text-[10px] text-muted mb-1">
{WEEKDAYS.map((w) => (
<div key={w}>{w}</div>
))}
</div>
{/* 日期格子 */}
<div className="grid grid-cols-7 gap-px">
{cells.map((c, i) => {
const isSelected = c.dateStr === value
const isToday = c.dateStr === today
return (
<button
key={i}
type="button"
disabled={c.disabled}
onClick={() => {
if (!c.disabled) {
onChange(c.dateStr)
setOpen(false)
}
}}
className={`
h-7 w-full text-xs rounded-btn transition-colors duration-100
${c.cur ? 'text-foreground' : 'text-muted/40'}
${isSelected ? 'bg-accent text-white font-bold' : ''}
${isToday && !isSelected ? 'border border-accent/40' : ''}
${!isSelected && !c.disabled ? 'hover:bg-elevated' : ''}
${c.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer'}
`}
>
{c.day}
</button>
)
})}
</div>
</>
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
File diff suppressed because it is too large Load Diff
+613
View File
@@ -0,0 +1,613 @@
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,
}
}
interface Props {
data: MinuteKlineRow[]
height?: number
prevClose?: number
date?: string
symbol?: string
onPriceHover?: (price: number | null) => void
showLimitLines?: boolean
showAvgLine?: boolean
}
function fmtTime(dt: string): string {
const match = dt.match(/(\d{2}):(\d{2})/)
if (!match) return dt.slice(11, 16)
const h = (parseInt(match[1]) + 8) % 24
return `${String(h).padStart(2, '0')}:${match[2]}`
}
function computeAvgPrice(data: MinuteKlineRow[]): number[] {
// 分时均线 = 累计成交额 / 累计成交量(手→股)
const result: number[] = []
let sumAmt = 0
let sumVol = 0
for (const d of data) {
sumAmt += d.amount
sumVol += d.volume * 100
result.push(sumVol > 0 ? sumAmt / sumVol : d.close)
}
return result
}
function fmtAmt(v: number): string {
if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(2)}亿`
if (v >= 10_000) return `${(v / 10_000).toFixed(0)}`
return v.toFixed(0)
}
function isValidPrice(v: number | null | undefined): v is number {
return typeof v === 'number' && Number.isFinite(v) && v > 0
}
/** 生成全天分时时间刻度 9:30 ~ 11:30, 13:00 ~ 15:00, 每分钟一个点 (共242个) */
function generateFullDayTimes(): string[] {
const times: string[] = []
// 上午 9:30 ~ 11:30 (121 分钟)
for (let h = 9; h <= 11; h++) {
const startM = h === 9 ? 30 : 0
const endM = h === 11 ? 30 : 59
for (let m = startM; m <= endM; m++) {
times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`)
}
}
// 下午 13:00 ~ 15:00 (121 分钟)
for (let h = 13; h <= 15; h++) {
const endM = h === 15 ? 0 : 59
for (let m = 0; m <= endM; m++) {
times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`)
}
}
return times
}
const FULL_DAY_TIMES = generateFullDayTimes()
/** 根据 symbol 判断涨跌停幅度 (创业板/科创板 ±20%, 北交所 ±30%, 其余 ±10%) */
function getLimitPct(symbol?: string): number {
if (!symbol) return 0.10
if (symbol.endsWith('.BJ')) return 0.30 // 北交所
if (symbol.startsWith('300') || symbol.startsWith('301')) return 0.20 // 创业板
if (symbol.startsWith('688') || symbol.startsWith('689')) return 0.20 // 科创板
return 0.10
}
/** 计算实际涨跌停价 (四舍五入到2位小数) 和实际涨跌停幅度 */
function getLimitPrices(prevClose: number, symbol?: string): {
limitUp: number // 涨停价 (四舍五入)
limitDown: number // 跌停价 (四舍五入)
upPct: number // 实际涨停幅度 (如 9.97)
downPct: number // 实际跌停幅度 (如 -9.97)
} {
const pct = getLimitPct(symbol)
const rawUp = prevClose * (1 + pct)
const rawDown = prevClose * (1 - pct)
// A股涨跌停价四舍五入到分 (2位小数)
const limitUp = Math.round(rawUp * 100) / 100
const limitDown = Math.round(rawDown * 100) / 100
const upPct = (limitUp - prevClose) / prevClose * 100
const downPct = (limitDown - prevClose) / prevClose * 100
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 {
// 将数据映射到全天时间轴上的正确位置
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)[]
const highs = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
const lows = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
const avgData = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
const volumes = new Array(FULL_DAY_TIMES.length).fill(null) as (any | null)[]
const volNeutral = 'rgba(161,161,170,0.5)'
for (let i = 0; i < data.length; i++) {
const timeKey = fmtTime(data[i].datetime)
const idx = timeIndexMap.get(timeKey)
if (idx !== undefined) {
closes[idx] = data[i].close
highs[idx] = data[i].high
lows[idx] = data[i].low
avgData[idx] = avgPrices[i]
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,
},
}
}
}
const areaStyle: any = {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: areaColor },
{ offset: 1, color: 'rgba(0,0,0,0)' },
],
},
}
const markLineData: any[] = []
if (prevClose != null) {
markLineData.push({
yAxis: prevClose,
lineStyle: { color: theme.refLine, type: 'dashed', width: 1 },
label: { show: false },
symbol: 'none',
})
}
let yMin: number | undefined
let yMax: number | undefined
let maxDiff = 0
if (isValidPrice(prevClose) && data.length > 0) {
const priceArrays = showAvgLine ? [closes, highs, lows, avgData] : [closes, highs, lows]
for (const arr of priceArrays) {
for (const v of arr) {
if (!isValidPrice(v)) continue
const diff = Math.abs(v - prevClose)
if (diff > maxDiff) maxDiff = diff
}
}
if (showLimitLines && yMode === 'limit') {
const { limitUp, limitDown } = getLimitPrices(prevClose, symbol)
const limitDiffUp = limitUp - prevClose
const limitDiffDown = prevClose - limitDown
const limitDiff = Math.max(limitDiffUp, limitDiffDown)
// 涨跌停模式: Y 轴按实际涨跌停价
maxDiff = limitDiff
yMin = prevClose - maxDiff
yMax = prevClose + maxDiff
// 加 markLine 标注涨停价和跌停价 (仅虚线, 不显示文字)
markLineData.push(
{
yAxis: limitUp,
lineStyle: { color: 'rgba(199,64,64,0.4)', type: 'dashed', width: 1 },
label: { show: false },
symbol: 'none',
},
{
yAxis: limitDown,
lineStyle: { color: 'rgba(45,155,101,0.4)', type: 'dashed', width: 1 },
label: { show: false },
symbol: 'none',
},
)
} else {
// 自适应模式: Y 轴按实际涨跌幅对称, 但不超出实际涨跌停范围
if (showLimitLines) {
const { limitUp, limitDown } = getLimitPrices(prevClose, symbol)
const limitDiff = Math.max(limitUp - prevClose, prevClose - limitDown)
maxDiff = Math.min(maxDiff, limitDiff)
}
if (!showLimitLines && maxDiff > 0) {
maxDiff *= 1.1
}
// 至少保证一个可视范围 (防止数据平时 maxDiff=0)。指数不使用涨跌停范围,最小范围要更紧,否则低波动指数会被压成横线。
const minDiff = showLimitLines ? prevClose * 0.01 : prevClose * 0.001
if (maxDiff < minDiff) maxDiff = minDiff
yMin = prevClose - maxDiff
yMax = prevClose + maxDiff
}
}
// x 轴标签: 9:30, 10:30, 11:30/13:00, 14:00, 15:00
// 11:30(idx 120) 和 13:00(idx 121) 相邻会重叠, 合并为一个标签
const xAxisLabelMap: Record<number, string> = {
0: '9:30',
60: '10:30',
120: '11:30/13:00',
181: '14:00',
241: '15:00',
}
const xAxisLabelFormatter = (_value: string, idx: number) => {
return xAxisLabelMap[idx] ?? ''
}
return {
animation: false,
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: 'transparent',
borderWidth: 0,
textStyle: { fontSize: 0 },
formatter: () => '',
axisPointer: {
type: 'cross',
label: {
show: true,
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
borderWidth: 1,
padding: [2, 5],
color: theme.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
},
crossStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
},
},
axisPointer: {
link: [{ xAxisIndex: 'all' }],
},
grid: [
{ left: 60, right: 55, top: 24, bottom: '28%' },
{ left: 60, right: 55, top: '74%', bottom: 20 },
],
xAxis: [
{
type: 'category',
data: FULL_DAY_TIMES,
boundaryGap: false,
axisPointer: {
show: true,
lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 },
label: {
show: true,
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
borderWidth: 1,
padding: [2, 4],
color: theme.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (params: any) => {
return params.value ?? ''
},
},
},
axisLine: { show: false },
axisLabel: {
color: theme.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: xAxisLabelFormatter,
interval: 0,
},
axisTick: { show: false },
splitLine: {
show: true,
lineStyle: { color: theme.grid },
},
},
{
type: 'category',
gridIndex: 1,
data: FULL_DAY_TIMES,
boundaryGap: false,
axisLine: { show: false },
axisLabel: { show: false },
axisTick: { show: false },
splitLine: { show: false },
},
],
yAxis: [
{
type: 'value',
min: yMin,
max: yMax,
interval: maxDiff || undefined,
splitArea: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: theme.grid } },
axisPointer: {
label: {
formatter: (params: any) => {
const v = params.value
return typeof v === 'number' ? v.toFixed(2) : ''
},
},
},
axisLabel: {
color: theme.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => v.toFixed(2),
},
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
},
...(isValidPrice(prevClose) && yMin != null && yMax != null ? [{
type: 'value' as const,
position: 'right' as const,
gridIndex: 0,
min: yMin,
max: yMax,
interval: maxDiff || undefined,
splitArea: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisPointer: {
label: {
formatter: (params: any) => {
const v = params.value
if (typeof v !== 'number') return ''
const pct = (v - prevClose) / prevClose * 100
if (Math.abs(pct) < 0.01) return '0.00%'
return (pct > 0 ? '+' : '') + pct.toFixed(2) + '%'
},
},
},
axisLabel: {
color: theme.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => {
const pct = (v - prevClose) / prevClose * 100
if (Math.abs(pct) < 0.01) return '0.00%'
return (pct > 0 ? '+' : '') + pct.toFixed(2) + '%'
},
},
}] : []),
],
series: [
{
name: '价格',
type: 'line',
data: closes,
smooth: false,
symbol: 'none',
cursor: 'crosshair',
lineStyle: { width: 1.2, color: lineColor },
areaStyle,
connectNulls: true,
markLine: markLineData.length > 0 ? { symbol: 'none', data: markLineData, animation: false, silent: true } : undefined,
},
...(showAvgLine ? [{
name: '均价',
type: 'line' as const,
data: avgData,
smooth: false,
symbol: 'none',
cursor: 'crosshair',
lineStyle: { width: 1, color: theme.avgLine },
connectNulls: true,
}] : []),
{
name: '成交量',
type: 'bar',
data: volumes,
xAxisIndex: 1,
yAxisIndex: 1,
cursor: 'crosshair',
},
],
}
}
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)
const moRef = useRef<MutationObserver | null>(null)
const dataRef = useRef(data)
dataRef.current = data
const onPriceHoverRef = useRef(onPriceHover)
onPriceHoverRef.current = onPriceHover
// 全日索引 → 数据数组索引 的映射 (ref 避免重建 chart)
const fullDayToDataIdx = useRef<Map<number, number>>(new Map())
const [infoIdx, setInfoIdx] = useState(data.length - 1)
const [yMode, setYMode] = useState<YMode>('adaptive')
const avgPrices = useMemo(() => computeAvgPrice(data), [data])
// 分时线颜色:基于最新价 vs 昨收
const lastClose = data.length > 0 ? data[data.length - 1].close : null
const lineIsUp = lastClose != null && prevClose != null ? lastClose > prevClose : true
const lineIsFlat = lastClose != null && prevClose != null ? lastClose === prevClose : false
const lineColor = lineIsFlat ? '#A1A1AA' : lineIsUp ? '#C74040' : '#2D9B65'
const areaFill = lineIsFlat ? 'rgba(180,180,190,0.40)' : lineIsUp ? 'rgba(199,64,64,0.40)' : 'rgba(34,197,94,0.40)'
useEffect(() => {
setInfoIdx(data.length - 1)
}, [data.length])
useEffect(() => {
const el = containerRef.current
if (!el) return
let chart = chartRef.current
if (!chart) {
chart = echarts.init(el, undefined, { renderer: 'canvas' })
chartRef.current = chart
// 强制 canvas 使用十字光标,覆盖 ECharts 默认的 pointer
const forceCursor = () => {
const canvases = el.querySelectorAll('canvas')
canvases.forEach(c => { c.style.setProperty('cursor', 'crosshair', 'important') })
}
forceCursor()
// MutationObserver: ECharts 内部可能重建/修改 canvas 属性,持续强制 cursor
const mo = new MutationObserver(forceCursor)
mo.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] })
moRef.current = mo
roRef.current = new ResizeObserver(() => {
chart!.resize()
forceCursor()
})
roRef.current.observe(el)
chart.on('updateAxisPointer', (event: any) => {
const axesInfo = event.axesInfo
if (!axesInfo) return
for (const info of Object.values(axesInfo)) {
const val = (info as any)?.value
if (val == null) continue
const fullDayIdx = typeof val === 'number' ? val : -1
if (fullDayIdx >= 0) {
const dataIdx = fullDayToDataIdx.current.get(fullDayIdx) ?? -1
setInfoIdx(dataIdx)
const d = dataRef.current
if (dataIdx >= 0 && dataIdx < d.length) {
onPriceHoverRef.current?.(d[dataIdx].close)
}
return
}
}
})
chart.on('globalout', () => {
onPriceHoverRef.current?.(null)
})
}
if (data.length > 0) {
// 构建全日索引 → 数据索引 的映射
const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i]))
const mapping = new Map<number, number>()
for (let i = 0; i < data.length; i++) {
const timeKey = fmtTime(data[i].datetime)
const fullDayIdx = timeIndexMap.get(timeKey)
if (fullDayIdx !== undefined) {
mapping.set(fullDayIdx, i)
}
}
fullDayToDataIdx.current = mapping
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, theme, symbol, showLimitLines, showAvgLine), true)
} else {
chart.clear()
}
}, [data, prevClose, height, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine, theme])
useEffect(() => {
return () => {
chartRef.current?.off('updateAxisPointer')
chartRef.current?.off('globalout')
moRef.current?.disconnect()
roRef.current?.disconnect()
chartRef.current?.dispose()
chartRef.current = null
moRef.current = null
roRef.current = null
}
}, [])
const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null
const avg = d != null ? avgPrices[infoIdx] : null
const chg = d && prevClose != null ? d.close - prevClose : null
const isUp = chg != null ? chg > 0 : true
const isFlat = chg != null ? chg === 0 : false
const priceClr = isFlat ? '#A1A1AA' : isUp ? '#C74040' : '#2D9B65'
return (
<div className="w-full">
{/* 按钮行: 切换式按钮组, 居右 */}
{showLimitLines && <div className="flex items-center justify-end px-1 pb-0.5">
<div className="inline-flex items-center rounded bg-elevated overflow-hidden">
<button
onClick={() => setYMode('adaptive')}
className={`px-2.5 py-0.5 text-[10px] font-mono cursor-pointer transition-colors ${
yMode === 'adaptive'
? 'bg-accent/20 text-accent'
: 'text-muted hover:text-secondary'
}`}
>
</button>
<div className="w-px h-3 bg-border/40" />
<button
onClick={() => setYMode('limit')}
className={`px-2.5 py-0.5 text-[10px] font-mono cursor-pointer transition-colors ${
yMode === 'limit'
? 'bg-accent/20 text-accent'
: 'text-muted hover:text-secondary'
}`}
>
</button>
</div>
</div>}
<div style={{ backgroundColor: theme.tooltipBg }}>
{/* 第一行: 日期 + 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>}
{d && (
<>
{date && <span className="text-muted">{date}</span>}
<span className="text-muted"></span>
<span style={{ color: priceClr }}>{d.open.toFixed(2)}</span>
<span className="text-muted"></span>
<span style={{ color: priceClr }}>{d.high.toFixed(2)}</span>
<span className="text-muted"></span>
<span style={{ color: priceClr }}>{d.low.toFixed(2)}</span>
<span className="text-muted"></span>
<span style={{ color: priceClr }} className="font-semibold">{d.close.toFixed(2)}</span>
</>
)}
</div>
{/* 第二行: 价格+均价+量+额 */}
<div className="flex items-center gap-x-4 px-2 font-mono text-[11px] select-none" style={{ height: 20 }}>
{d && (
<>
<span className="flex items-center gap-x-1">
<span style={{ display: 'inline-block', width: 14, height: 2, background: priceClr }} />
<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>}
<span className="text-muted"></span>
<span className="text-secondary">{d.volume.toFixed(0)}</span>
<span className="text-muted"></span>
<span className="text-secondary">{fmtAmt(d.amount)}</span>
</>
)}
</div>
</div>
<div ref={containerRef} className="w-full" style={{ height: height - 42, cursor: 'crosshair' }} />
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { type LucideIcon, Construction } from 'lucide-react'
interface Props {
icon?: LucideIcon
title: string
hint?: string
}
// §6.0.5 四态评审 — empty 状态:图示 + 引导,而不是一句"暂无数据"
export function EmptyState({ icon: Icon = Construction, title, hint }: Props) {
return (
<div className="h-full grid place-items-center px-8 py-16">
<div className="text-center max-w-md">
<Icon className="mx-auto h-10 w-10 text-muted" strokeWidth={1.5} />
<h2 className="mt-4 text-base font-medium text-foreground">{title}</h2>
{hint && <p className="mt-2 text-sm text-secondary leading-relaxed">{hint}</p>}
</div>
</div>
)
}
@@ -0,0 +1,263 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Wifi, Play, Loader2, X, Check, Crown } from 'lucide-react'
import { api, type EndpointItem } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { EXPERT_RANK, tierRank } from '@/lib/capability-labels'
interface EpResult {
ok: boolean
median_ms?: number | null
min_ms?: number | null
max_ms?: number | null
rounds?: number
success?: number
error?: string
}
export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose }: { hasKey: boolean; tierLabel: string; currentEndpoint: string; onClose: () => void }) {
const qc = useQueryClient()
const [results, setResults] = useState<Record<string, EpResult | null>>({})
const [testing, setTesting] = useState<Record<string, boolean>>({})
const [switching, setSwitching] = useState<string | null>(null)
// 动态加载端点清单 —— 前端无法跨域直连数据源官网,走后端代理
const { data, isLoading } = useQuery({
queryKey: QK.endpoints,
queryFn: api.listEndpoints,
staleTime: 5 * 60 * 1000,
})
const endpoints = data?.endpoints ?? []
const isFallback = data?.source === 'fallback'
const testRounds = data?.testRounds
async function testOne(url: string) {
setTesting(prev => ({ ...prev, [url]: true }))
setResults(prev => ({ ...prev, [url]: null }))
try {
const res = await api.testEndpoint(url, testRounds)
setResults(prev => ({ ...prev, [url]: res }))
} catch (e: any) {
setResults(prev => ({ ...prev, [url]: { ok: false, error: e?.message ?? '请求失败' } }))
} finally {
setTesting(prev => ({ ...prev, [url]: false }))
}
}
async function testAll() {
setResults({})
await Promise.all(endpoints.map(ep => testOne(ep.url)))
}
const anyTesting = Object.values(testing).some(Boolean)
const isFree = !hasKey
// 专线端点需 Expert 及以上套餐;Free 模式必然不可用
const canUsePremium = !isFree && tierRank(tierLabel) >= EXPERT_RANK
const currentLabel = endpoints.find(ep => ep.url === currentEndpoint)?.label ?? currentEndpoint
async function applyEndpoint(url: string) {
setSwitching(url)
try {
await api.switchEndpoint(url)
await qc.invalidateQueries({ queryKey: QK.settings })
onClose()
} catch {
// 错误由 query 处理
} finally {
setSwitching(null)
}
}
return (
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<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-[480px] max-h-[90vh] flex flex-col rounded-card border border-border bg-base shadow-2xl overflow-hidden"
>
{/* 顶栏 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-foreground"></span>
</div>
<div className="flex items-center gap-3">
{isFree && (
<span className="text-[10px] px-2 py-0.5 rounded bg-warning/10 text-warning/80">Free </span>
)}
<button
onClick={testAll}
disabled={isFree || anyTesting || endpoints.length === 0}
title={isFree ? 'Free 模式不可使用付费端点,请先配置 API Key' : undefined}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-btn bg-accent/15 text-accent text-xs font-medium hover:bg-accent/25 disabled:opacity-50 transition-colors"
>
{anyTesting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
</button>
<button onClick={onClose} className="p-1 rounded text-secondary hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 当前使用 */}
<div className="mx-4 mt-3 px-3 py-2 rounded-btn bg-accent/8 border border-accent/20 flex items-center gap-2 shrink-0">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_4px_rgba(61,214,140,0.5)]" />
<span className="text-[11px] text-secondary">使</span>
<span className="text-[11px] font-medium text-foreground">{currentLabel}</span>
<span className="text-[10px] text-muted font-mono ml-auto">{currentEndpoint.replace('https://', '')}</span>
</div>
{/* Free 模式提示 —— 以下均为 Starter+ 付费端点 */}
{isFree && (
<div className="mx-4 mt-2 px-3 py-1.5 rounded-btn bg-warning/8 border border-warning/20 shrink-0">
<span className="text-[10px] text-warning/80 leading-snug">
Starter+ Free 使 API Key
</span>
</div>
)}
{/* 端点列表 —— 可滚动区,顶栏/当前使用/底栏始终可见 */}
<div className="p-4 space-y-2 overflow-y-auto min-h-0">
{isLoading ? (
<div className="py-10 flex items-center justify-center gap-2 text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs"></span>
</div>
) : endpoints.length === 0 ? (
<div className="py-10 text-center text-xs text-muted"></div>
) : (
endpoints.map(ep => (
<EpRow key={ep.url} ep={ep} result={results[ep.url]} testing={testing[ep.url]} isCurrent={ep.url === currentEndpoint} isFree={isFree} canUsePremium={canUsePremium} switching={switching} onApply={applyEndpoint} />
))
)}
</div>
{isFallback ? (
<span className="text-[10px] text-warning/70"></span>
) : null}
</motion.div>
</div>
</AnimatePresence>
)
}
function EpRow({ ep, result, testing, isCurrent, isFree, canUsePremium, switching, onApply }: {
ep: EndpointItem
result: EpResult | null
testing?: boolean
isCurrent?: boolean
isFree?: boolean
canUsePremium?: boolean
switching: string | null
onApply: (url: string) => void
}) {
const isError = result && !result.ok
const canApply = result?.ok && !testing && !isCurrent
const isPremium = ep.premium === true
const median = result?.median_ms
return (
<div
className={`flex items-start gap-3 px-3 py-2.5 rounded-btn border transition-colors ${
isError
? 'border-danger/30 bg-danger/5'
: result?.ok
? 'border-bear/20 bg-bear/5'
: isCurrent
? 'border-accent/20 bg-accent/5'
: 'border-border bg-surface'
}`}
>
<div className="flex-1 min-w-0">
{/* 第1行:label + 徽章(左) / 中位延迟(右) */}
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="text-xs font-medium text-foreground">{ep.label}</span>
{isPremium && (
<span className="inline-flex items-center gap-0.5 text-[9px] px-1.5 py-px rounded-sm bg-warning/15 text-warning font-medium" title={ep.description ?? '需专线加速权限'}>
<Crown className="h-2.5 w-2.5" />
线
</span>
)}
{isCurrent && (
<span className="text-[9px] px-1.5 py-px rounded-sm bg-accent/15 text-accent font-medium">使</span>
)}
</div>
{/* 中位延迟 —— 测试中/前/后都占位,避免高度跳动 */}
<span className="ml-auto shrink-0 text-sm font-mono font-medium tabular-nums leading-none">
{isFree ? (
// Free 模式:普通付费端点需 Starter+,premium 端点需 Expert+
<span
className={`text-[10px] px-1.5 py-0.5 rounded-sm font-medium font-sans ${
isPremium
? 'bg-warning/15 text-warning'
: 'bg-muted/10 text-muted/70'
}`}
title={isPremium ? '需 Expert 及以上套餐' : '需 Starter+ 套餐'}
>
{isPremium ? 'Expert+' : 'Starter+'}
</span>
) : testing ? (
<span className="text-[11px] text-muted animate-pulse"></span>
) : result && result.ok && median != null ? (
<span className={median < 500 ? 'text-bear' : median < 1000 ? 'text-warning' : 'text-danger'}>
{median} ms
</span>
) : result && !result.ok ? (
<span className="text-[11px] text-danger">{result.error ?? '不可达'}</span>
) : (
<span className="text-[11px] text-muted/40"></span>
)}
</span>
</div>
{/* 第2行:description */}
<span className="block text-[10px] text-muted/70 leading-snug mt-0.5 break-words">{ep.description}</span>
{/* 第3行:URL(左) / min~max·成功率(右) —— 副信息始终占位,行数不变 */}
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-muted/50 font-mono truncate min-w-0" title={ep.url}>{ep.url.replace('https://', '')}</span>
<span className="ml-auto shrink-0 text-[9px] text-muted/50 font-mono whitespace-nowrap">
{result && result.ok && result.min_ms != null
? `${result.min_ms}~${result.max_ms} · ${result.success}/${result.rounds}`
: '\u00A0'}
</span>
</div>
</div>
{/* 应用按钮区域 —— Free 模式不可用任何付费端点;专线端点需 Expert+ */}
{isFree ? null : (isPremium && !canUsePremium) ? (
// 专线端点:需 Expert 及以上套餐权限,当前套餐不足,不可应用
<span
className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] font-medium bg-warning/10 text-warning/70 cursor-not-allowed select-none mt-0.5"
title="需要 Expert 及以上套餐的专线加速权限"
>
<Crown className="h-3 w-3" />
Expert+
</span>
) : canApply ? (
<button
onClick={() => onApply(ep.url)}
disabled={switching !== null}
className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] font-medium bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50 transition-colors mt-0.5"
>
{switching === ep.url ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</button>
) : null}
</div>
)
}
@@ -0,0 +1,436 @@
import { useMemo, useState, type ComponentType } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3,
CalendarDays,
Database,
Layers3,
Search,
Tags,
TrendingUp,
Users,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { api, type AnalysisColumn, type ExtDataConfig, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
interface DimensionAnalysisProps {
menuId?: string
title?: string
subtitle?: string
kindLabel?: string
emptyHint?: string
accentClass?: string
keywords?: string[]
fallbackFieldNames?: string[]
}
interface DimensionGroup {
key: string
count: number
rows: Record<string, any>[]
metrics: Record<string, number | null>
}
const PAGE_LIMIT = 5000
const SEPARATORS = /[、,;|/\s]+/
function normalizeText(v: unknown): string {
if (v == null) return ''
return String(v).trim()
}
function fieldLabel(fields: ExtDataField[], name: string): string {
return fields.find(f => f.name === name)?.label || name
}
function isNumericField(f: ExtDataField) {
return f.dtype === 'int' || f.dtype === 'float'
}
function scoreConfig(config: ExtDataConfig, keywords: string[]) {
const haystack = [
config.id,
config.label,
config.description ?? '',
...config.fields.flatMap(f => [f.name, f.label]),
].join(' ').toLowerCase()
return keywords.reduce((n, k) => n + (haystack.includes(k.toLowerCase()) ? 1 : 0), 0)
}
function pickDefaultConfig(configs: ExtDataConfig[], keywords: string[]) {
const ranked = [...configs].sort((a, b) => scoreConfig(b, keywords) - scoreConfig(a, keywords))
return ranked[0]?.id ?? ''
}
function pickDefaultDimensionField(config: ExtDataConfig | undefined, fallbackFieldNames: string[]) {
if (!config) return ''
const fields = config.fields.filter(f => f.name !== 'symbol' && f.name !== 'code')
for (const name of fallbackFieldNames) {
const matched = fields.find(f => f.name.toLowerCase().includes(name) || f.label.toLowerCase().includes(name))
if (matched) return matched.name
}
return fields.find(f => !isNumericField(f))?.name ?? fields[0]?.name ?? ''
}
function formatNumber(v: unknown, digits = 2) {
if (typeof v !== 'number' || !Number.isFinite(v)) return '—'
return v.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function formatValue(v: unknown, col?: AnalysisColumn) {
if (v == null || v === '') return '—'
if (typeof v === 'boolean') return v ? '是' : '否'
if (typeof v !== 'number') return String(v)
const digits = col?.precision ?? (col?.type === 'number' || col?.type === 'amount' || col?.type === 'percent' ? 2 : 2)
if (col?.type === 'percent') return `${formatNumber(v, digits)}%`
if (col?.type === 'amount' || col?.format === 'amount') return formatNumber(v, digits)
return formatNumber(v, digits)
}
function splitDimensionValues(value: unknown) {
const text = normalizeText(value)
if (!text) return []
return text.split(SEPARATORS).map(s => s.trim()).filter(Boolean)
}
function StatCard({ label, value, hint, icon: Icon }: {
label: string
value: string | number
hint: string
icon: ComponentType<{ className?: string }>
}) {
return (
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted">{label}</span>
<Icon className="h-4 w-4 text-secondary" />
</div>
<div className="mt-3 text-2xl font-semibold tracking-tight text-foreground">{value}</div>
<div className="mt-1 text-[11px] text-muted">{hint}</div>
</div>
)
}
function columnsFromFields(fields: ExtDataField[], dimensionField: string, rows: Record<string, any>[]) {
const numericFields = fields.filter(f => isNumericField(f) && rows.some(r => typeof r[f.name] === 'number')).slice(0, 4)
return [
...fields.filter(f => ['symbol', 'code', 'name', '股票简称', '股票代码'].includes(f.name)),
...fields.filter(f => f.name === dimensionField),
...numericFields,
]
.filter((f, i, arr) => arr.findIndex(x => x.name === f.name) === i)
.map<AnalysisColumn>(f => ({
field: f.name,
label: f.label || f.name,
type: isNumericField(f) ? 'number' : 'string',
precision: f.dtype === 'float' ? 2 : null,
sortable: isNumericField(f),
visible: true,
}))
}
function aggregate(rows: Record<string, any>[], col: AnalysisColumn) {
if (col.field === '__count') return rows.length
const values = rows.map(r => r[col.field]).filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
if (!values.length) return null
switch (col.aggregate) {
case 'sum': return values.reduce((a, b) => a + b, 0)
case 'min': return Math.min(...values)
case 'max': return Math.max(...values)
case 'avg':
default:
return values.reduce((a, b) => a + b, 0) / values.length
}
}
export function ExtDimensionAnalysis({
menuId,
title,
subtitle,
kindLabel = '维度',
emptyHint = '还没有可用于分析的扩展数据。',
accentClass = 'bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.16),transparent_36%)]',
keywords = [],
fallbackFieldNames = [],
}: DimensionAnalysisProps) {
const configs = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const menuQuery = useQuery({
queryKey: QK.analysisMenu(menuId ?? ''),
queryFn: () => api.analysisMenu(menuId!),
enabled: !!menuId,
})
const menu = menuQuery.data
const [selectedConfigId, setSelectedConfigId] = useState('')
const [dimensionField, setDimensionField] = useState('')
const [search, setSearch] = useState('')
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
const availableConfigs = configs.data?.items ?? []
const activeConfigId = menu?.data_source || selectedConfigId || pickDefaultConfig(availableConfigs, keywords)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const activeDimensionField = menu?.dimension_field || dimensionField || pickDefaultDimensionField(activeConfig, fallbackFieldNames)
const activeTitle = menu?.label || title || '扩展分析'
const activeKindLabel = menu?.template === 'table' ? '数据' : kindLabel
const configuredColumns = useMemo(() => {
const cols = menu?.detail_columns?.filter(c => c.visible !== false) ?? []
return cols.length > 0 ? cols : null
}, [menu?.detail_columns])
const requestedColumns = useMemo(() => {
const cols = [activeDimensionField, ...(configuredColumns?.map(c => c.field) ?? [])].filter(Boolean)
return Array.from(new Set(cols))
}, [activeDimensionField, configuredColumns])
const columnsKey = requestedColumns.join(',')
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT, columnsKey),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT, columns: requestedColumns }),
enabled: !!activeConfigId,
})
const rows = rowsQuery.data?.rows ?? []
const baseFields = activeConfig?.fields ?? rowsQuery.data?.fields ?? []
const fields = rows.some(r => r.name != null) && !baseFields.some(f => f.name === 'name')
? [...baseFields, { name: 'name', dtype: 'string', label: '名称' }]
: baseFields
const dimensionOptions = fields.filter(f => f.name !== 'symbol' && f.name !== 'code')
const displayColumns = configuredColumns ?? columnsFromFields(fields, activeDimensionField, rows)
const groupColumns = (menu?.group_columns?.length ? menu.group_columns : [
{ field: '__dimension', label: activeKindLabel },
{ field: '__count', label: '股票数', type: 'number' as const, sortable: true },
]).filter(c => c.visible !== false)
const sortedRows = useMemo(() => {
if (!menu?.default_sort?.field) return rows
const factor = menu.default_sort.order === 'asc' ? 1 : -1
return [...rows].sort((a, b) => {
const av = a[menu.default_sort!.field]
const bv = b[menu.default_sort!.field]
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor
return String(av ?? '').localeCompare(String(bv ?? '')) * factor
})
}, [rows, menu?.default_sort])
const groups = useMemo<DimensionGroup[]>(() => {
if (!activeDimensionField || menu?.template === 'table' || menu?.template === 'ranking') return []
const map = new Map<string, Record<string, any>[]>()
for (const row of sortedRows) {
const values = splitDimensionValues(row[activeDimensionField])
for (const value of values) {
const item = map.get(value) ?? []
item.push(row)
map.set(value, item)
}
}
return [...map.entries()]
.map(([key, itemRows]) => ({
key,
count: itemRows.length,
rows: itemRows,
metrics: Object.fromEntries(groupColumns.map(col => [col.field, aggregate(itemRows, col)])),
}))
.sort((a, b) => b.count - a.count)
}, [sortedRows, activeDimensionField, groupColumns, menu?.template])
const filteredGroups = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return groups
return groups.filter(g => g.key.toLowerCase().includes(q))
}, [groups, search])
const currentGroup = filteredGroups.find(g => g.key === selectedGroup) ?? filteredGroups[0]
const tableRows = menu?.template === 'table' || menu?.template === 'ranking' ? sortedRows : (currentGroup?.rows ?? [])
const coveredSymbols = useMemo(() => {
const set = new Set<string>()
rows.forEach(r => { if (r.symbol) set.add(String(r.symbol)) })
return set.size
}, [rows])
const missingConfiguredColumns = displayColumns.filter(c => rows.length > 0 && !Object.prototype.hasOwnProperty.call(rows[0], c.field))
return (
<>
<PageHeader
title={activeTitle}
subtitle={menu ? `${menu.template} · ${menu.data_source}` : subtitle}
right={
<div className="flex items-center gap-2">
<select
value={activeConfigId}
onChange={(e) => { setSelectedConfigId(e.target.value); setDimensionField(''); setSelectedGroup(null) }}
className="h-8 min-w-40 rounded-btn border border-border bg-surface px-2 text-xs text-foreground focus:outline-none focus:border-accent/50"
>
{availableConfigs.length === 0 ? (
<option value=""></option>
) : availableConfigs.map(config => (
<option key={config.id} value={config.id}>{config.label}</option>
))}
</select>
<select
value={activeDimensionField}
onChange={(e) => { setDimensionField(e.target.value); setSelectedGroup(null) }}
disabled={!activeConfig}
className="h-8 min-w-36 rounded-btn border border-border bg-surface px-2 text-xs text-foreground disabled:opacity-50 focus:outline-none focus:border-accent/50"
>
{dimensionOptions.length === 0 ? (
<option value=""></option>
) : dimensionOptions.map(field => (
<option key={field.name} value={field.name}>{field.label || field.name}</option>
))}
</select>
</div>
}
/>
<div className="px-8 py-6 space-y-6 max-w-7xl">
<section className={`relative overflow-hidden rounded-2xl border border-border bg-surface p-6 ${accentClass}`}>
<div className="relative z-10 max-w-3xl">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] text-secondary">
<Layers3 className="h-3.5 w-3.5" />
· ·
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-foreground">{activeTitle}</h2>
<p className="mt-2 text-sm leading-6 text-secondary">
使 detail_columns
</p>
</div>
<div className="absolute right-8 top-6 hidden h-28 w-28 rounded-full border border-white/10 bg-white/[0.03] lg:flex items-center justify-center">
<BarChart3 className="h-12 w-12 text-white/20" />
</div>
</section>
{configs.isLoading || menuQuery.isLoading ? (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted"></div>
) : menuQuery.isError ? (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted"></div>
) : !activeConfig ? (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center">
<Database className="mx-auto h-8 w-8 text-muted" />
<div className="mt-3 text-sm text-secondary">{emptyHint}</div>
<div className="mt-1 text-xs text-muted"></div>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard label="数据源" value={activeConfig.label} hint={`${activeConfig.mode === 'snapshot' ? '快照' : '时序'} · ${activeConfig.id}`} icon={Database} />
<StatCard label="分组数量" value={groups.length || '—'} hint={activeDimensionField ? `${fieldLabel(fields, activeDimensionField)} 聚合` : '未配置分组字段'} icon={Tags} />
<StatCard label="覆盖标的" value={coveredSymbols || rows.length} hint={rowsQuery.data?.date ? `数据日期 ${rowsQuery.data.date}` : '当前快照'} icon={Users} />
<StatCard label="列表列数" value={displayColumns.length} hint={`配置列 ${displayColumns.length}`} icon={TrendingUp} />
</div>
{missingConfiguredColumns.length > 0 && (
<div className="rounded-card border border-warning/40 bg-warning/5 px-4 py-3 text-xs text-warning">
{missingConfiguredColumns.map(c => c.field).join('、')}
</div>
)}
<div className={menu?.template === 'table' || menu?.template === 'ranking' ? 'grid grid-cols-1' : 'grid grid-cols-1 xl:grid-cols-[24rem_1fr] gap-5'}>
{menu?.template !== 'table' && menu?.template !== 'ranking' && (
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground"></h3>
<p className="mt-0.5 text-[11px] text-muted"></p>
</div>
<span className="text-[10px] text-muted">Top {Math.min(filteredGroups.length, 100)}</span>
</div>
<div className="p-3 border-b border-border/60">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setSelectedGroup(null) }}
placeholder={`搜索${activeKindLabel}`}
className="h-8 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div className="max-h-[560px] overflow-auto p-2 space-y-1">
{filteredGroups.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-muted"></div>
) : filteredGroups.slice(0, 100).map((group, i) => {
const active = currentGroup?.key === group.key
return (
<button
key={group.key}
onClick={() => setSelectedGroup(group.key)}
className={`w-full rounded-lg px-3 py-2 text-left transition-colors ${active ? 'bg-accent/10 border border-accent/25' : 'border border-transparent hover:bg-elevated/60'}`}
>
<div className="flex items-center gap-2">
<span className="w-5 text-[10px] font-mono text-muted">#{i + 1}</span>
<span className="flex-1 truncate text-xs font-medium text-foreground">{group.key}</span>
<span className="font-mono text-xs text-secondary">{group.count}</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-accent/70"
style={{ width: `${Math.max(6, (group.count / (filteredGroups[0]?.count || 1)) * 100)}%` }}
/>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] text-muted">
{groupColumns.filter(c => !['__dimension', '__count'].includes(c.field)).slice(0, 3).map(col => (
<span key={col.field}>{col.label || col.field}: {formatValue(group.metrics[col.field], col)}</span>
))}
</div>
</button>
)
})}
</div>
</section>
)}
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="px-5 py-4 border-b border-border flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-foreground">{menu?.template === 'table' || menu?.template === 'ranking' ? '明细列表' : currentGroup?.key ?? `选择${activeKindLabel}`}</h3>
<span className="rounded-full bg-accent/10 px-2 py-0.5 text-[10px] text-accent">{tableRows.length} </span>
</div>
<p className="mt-1 text-xs text-muted"> detail_columns{displayColumns.map(f => f.label || f.field).join(' / ') || '暂无字段'}</p>
</div>
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted">
<CalendarDays className="h-3.5 w-3.5" />
{rowsQuery.data?.date ?? '当前快照'}
</div>
</div>
<div className="overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/50 text-[11px] text-muted">
<tr>
{displayColumns.map(col => (
<th key={col.field} className="whitespace-nowrap px-4 py-2 font-medium" style={col.width ? { width: col.width } : undefined}>{col.label || col.field}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{rowsQuery.isLoading ? (
<tr><td className="px-4 py-8 text-center text-muted" colSpan={Math.max(displayColumns.length, 1)}></td></tr>
) : tableRows.length === 0 ? (
<tr><td className="px-4 py-8 text-center text-muted" colSpan={Math.max(displayColumns.length, 1)}></td></tr>
) : tableRows.slice(0, 300).map((row, i) => (
<tr key={`${row.symbol ?? row.code ?? i}-${i}`} className="hover:bg-elevated/30">
{displayColumns.map(col => (
<td key={col.field} className={`whitespace-nowrap px-4 py-2 ${col.field === activeDimensionField ? 'max-w-[18rem] truncate text-secondary' : col.field === 'symbol' || col.field === 'code' || col.field === '股票代码' ? 'font-mono text-secondary' : 'text-foreground'}`}>
{formatValue(row[col.field], col)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{tableRows.length > 300 && (
<div className="border-t border-border px-4 py-2 text-center text-[11px] text-muted"> 300 {tableRows.length} </div>
)}
</section>
</div>
</>
)}
</div>
</>
)
}
+539
View File
@@ -0,0 +1,539 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useQuoteStream } from '@/lib/useQuoteStream'
import { ToastContainer } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast'
import {
useCapabilities,
useSettings,
usePreferences,
useQuoteStatus,
useVersion,
} from '@/lib/useSharedQueries'
import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
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,
} 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 CORE_INDEXES = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
] as const
type CoreIndex = (typeof CORE_INDEXES)[number]
const nav = [
{ to: '/', label: '看板', icon: LayoutDashboard },
{ to: '/watchlist', label: '自选', icon: Star },
{ to: '/screener', label: '策略', icon: ScanSearch },
{ to: '/backtest', label: '回测', icon: History },
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
{ to: '/financials', label: '财务', icon: FileText },
{ to: '/indices', label: '指数', icon: BarChart3 },
{ to: '/trading', label: '交易', icon: Cable },
{ to: '/monitor', label: '监控中心', icon: RadioTower },
{ to: '/data', label: '数据', icon: Database },
] as const
function fmtIndexValue(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return Number(v).toFixed(2)
}
function fmtIndexPct(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return `${Number(v) >= 0 ? '+' : ''}${Number(v).toFixed(2)}%`
}
function indexPctClass(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return 'text-muted'
const n = Number(v)
if (n === 0) return 'text-foreground'
return n > 0 ? 'text-bull' : 'text-bear'
}
/** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */
function MonitorBadge({ active }: { active: boolean }) {
const unread = useUnreadAlerts()
// 尊重用户设置: 可在菜单设置里关闭数字提示
const badgeEnabled = (() => {
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
})()
if (active || unread <= 0 || !badgeEnabled) return null
return (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[9px] font-bold text-white animate-pulse">
{unread > 99 ? '99+' : unread}
</span>
)
}
function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; items: CoreIndex[] }) {
if (items.length === 0) return null
const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q]))
return (
<div className="mt-2 grid grid-cols-2 gap-1.5">
{items.map(item => {
const q = quoteBySymbol.get(item.symbol)
const value = q?.last_price ?? q?.close
const pct = q?.change_pct
return (
<NavLink
key={item.symbol}
to={`/indices?symbol=${encodeURIComponent(item.symbol)}`}
className="block rounded bg-elevated/60 px-2 py-1.5 transition-colors hover:bg-elevated"
title={`${item.name} ${item.symbol}`}
>
<div className="flex items-center justify-between gap-1">
<span className="text-[10px] text-secondary">{item.name}</span>
<span className={`text-[10px] font-mono ${indexPctClass(pct)}`}>{fmtIndexPct(pct)}</span>
</div>
<div className="mt-0.5 truncate font-mono text-[10px] text-foreground/80">
{fmtIndexValue(value)}
</div>
</NavLink>
)
})}
</div>
)
}
// ===== 档位卡片 =====
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
const base = label.split(' ')[0].split('+')[0].toLowerCase()
const isNone = base === 'none'
const tierConfig: Record<string, {
desc: string
tagBg: React.CSSProperties
dotStyle: React.CSSProperties
labelTextStyle: React.CSSProperties
}> = {
none: {
desc: '未配置 Key · 仅历史日K',
tagBg: { background: 'rgba(113,113,122,0.15)' },
dotStyle: { background: '#52525b' },
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '基础日K · 单股查询',
tagBg: { background: 'rgba(113,113,122,0.3)' },
dotStyle: { background: '#71717a' },
labelTextStyle: { color: '#a1a1aa' },
},
starter: {
desc: '批量同步 · 行情池',
tagBg: { background: 'rgba(59,130,246,0.2)' },
dotStyle: { background: '#3b82f6' },
labelTextStyle: { color: '#60a5fa' },
},
pro: {
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' },
},
expert: {
desc: 'WebSocket · 财务数据',
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
}
const t = tierConfig[base] || tierConfig.none
// none 档显示中文「无」,无 label 时显示「无档」
const displayLabel = isNone ? '无' : (label || '无')
return (
<NavLink
to="/settings?tab=account"
className="mt-2.5 group block -mx-2.5"
title="API 设置"
>
<div className="relative overflow-hidden rounded-lg border border-blue-400/20 bg-gradient-to-br from-blue-500/[0.12] via-surface to-surface px-3 py-2 transition-all hover:border-blue-400/35 hover:from-blue-500/[0.16]">
<div className="absolute -right-5 -top-6 h-14 w-14 rounded-full bg-blue-500/10 blur-2xl" />
<div className="relative flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-blue-400/10 text-blue-300 ring-1 ring-blue-400/20">
<Key className="h-3.5 w-3.5" />
</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="h-1.5 w-1.5 rounded-full"
style={{ ...t.dotStyle, ...(base === 'expert' ? { animation: 'pulse 2s infinite' } : {}) }}
/>
</div>
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted">
{isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc}
</div>
</div>
<span
className="inline-flex h-[18px] max-w-[68px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none"
style={t.tagBg}
>
<span className="truncate" style={t.labelTextStyle}>{displayLabel}</span>
</span>
<Settings className="h-3 w-3 shrink-0 text-muted group-hover:text-blue-300 transition-colors" />
</div>
</div>
</NavLink>
)
}
function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) {
return (
<NavLink
to="/settings?tab=ai"
className="mt-2 group block -mx-2.5"
title="AI 配置"
>
<div className="relative overflow-hidden rounded-lg border border-purple-400/20 bg-gradient-to-br from-purple-500/[0.12] via-surface to-surface px-3 py-2 transition-all hover:border-purple-400/35 hover:from-purple-500/[0.16]">
<div className="absolute -right-5 -top-6 h-14 w-14 rounded-full bg-purple-500/10 blur-2xl" />
<div className="relative flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-purple-400/10 text-purple-300 ring-1 ring-purple-400/20">
<Sparkles className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-foreground">AI </span>
<span className={`h-1.5 w-1.5 rounded-full ${configured ? 'bg-bear' : 'bg-warning'}`} />
</div>
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted">
{configured ? (model || '已接入模型') : '接入策略生成模型'}
</div>
</div>
<Settings className="h-3 w-3 text-muted group-hover:text-purple-300 transition-colors" />
</div>
</div>
</NavLink>
)
}
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()
const { data: analysisMenus } = useQuery({
queryKey: QK.analysisMenus,
queryFn: api.analysisMenus,
})
// 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈
const { data: pipelineJobs } = useQuery({
queryKey: QK.pipelineJobs,
queryFn: () => api.pipelineJobs(1),
refetchInterval: (query) => (query.state.data?.active_id ? 2000 : 15000),
refetchIntervalInBackground: true,
})
const isDataSyncing = !!pipelineJobs?.active_id
// 数据同步完成的"瞬时反馈": isDataSyncing 从 true→false 时显示绿色对勾,
// 闪烁约 3 秒后自动消失。
const [dataSyncJustDone, setDataSyncJustDone] = useState(false)
const prevSyncingRef = useRef(false)
useEffect(() => {
// 仅在"刚结束"(true→false)且非首次挂载时触发
if (prevSyncingRef.current && !isDataSyncing) {
setDataSyncJustDone(true)
const t = setTimeout(() => setDataSyncJustDone(false), 3000)
prevSyncingRef.current = isDataSyncing
return () => clearTimeout(t)
}
prevSyncingRef.current = isDataSyncing
}, [isDataSyncing])
const qc = useQueryClient()
const navigate = useNavigate()
const version = versionData?.version
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? 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))
// 卡片数据:固定显示时也拉取(即使实时行情关闭)
const showSidebarQuotes = indicesPinned || realtimeEnabled
const { data: sidebarIndexQuotes } = useQuery({
queryKey: [...QK.indexQuotes, 'sidebar', sidebarIndexSymbols.join(',')] as const,
queryFn: () => api.indexQuotes(sidebarIndexes.map(p => p.symbol)),
enabled: showSidebarQuotes && sidebarIndexes.length > 0,
placeholderData: (prev) => prev,
})
// SSE: 行情更新时自动刷新相关 queries + 告警通知
useQuoteStream(realtimeEnabled, prefs?.sse_refresh_pages)
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
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
const alertsTotalQuery = useQuery({
queryKey: ['alerts-total'],
queryFn: () => api.alertsList({ days: 7, limit: 1 }),
refetchInterval: 15000,
refetchIntervalInBackground: true,
select: (data) => data.total,
})
// 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen)
const alertsTotal = alertsTotalQuery.data
useEffect(() => {
if (alertsTotal != null) setAlertTotal(alertsTotal)
}, [alertsTotal])
// 合并内置页面 + 可见的扩展分析菜单
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 ?? []
const navItems = savedOrder.length > 0
? (() => {
const byTo = new Map(allNav.map(n => [n.to, n]))
const ordered = savedOrder
.map(id => byTo.get(id) ?? byTo.get(`/analysis/${id}`))
.filter(Boolean)
const seen = new Set(ordered.map(n => n!.to))
return [...ordered as typeof allNav, ...allNav.filter(n => !seen.has(n.to))]
})()
: allNav
const hiddenIds = new Set(prefs?.nav_hidden ?? [])
const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, '')))
const handleToggle = async (enabled: boolean) => {
// 开启时重新校验档位
if (enabled) {
const fresh = await qc.fetchQuery({
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
if (tierRank(fresh.label ?? '') < 1) return
}
await toggleQuote.mutateAsync(enabled)
// 仅在交易时段立即获取一次行情
if (enabled && isTrading) {
api.intradayRefresh().catch(() => {})
}
}
return (
<div className="h-screen grid grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden">
<div className="px-5 py-5 border-b border-border shrink-0">
{/* Brand block — 原创 logo + 等宽 wordmark */}
<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)]')}
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' }}
>
<div>A股</div>
<div></div>
</div>
</div>
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
</div>
<div
className="mt-3 h-px"
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
/>
{settingsState?.mode === 'none' && (
<TierBadge
label={caps?.label ?? ''}
hasKey={false}
/>
)}
<AIConfigBadge
configured={settingsState?.has_ai_key}
model={settingsState?.ai_model}
/>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto px-2 py-3 space-y-0.5">
{visibleNavItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
isActive
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
)
}
>
{({ isActive }) => (
<>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{label}</span>
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
{to === '/data' && isDataSyncing && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
)}
{to === '/data' && !isDataSyncing && dataSyncJustDone && (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-bull animate-pulse" />
)}
{/* 监控中心徽标: 仅非监控页且有未读时显示 */}
{to === '/monitor' && <MonitorBadge active={isActive} />}
</>
)}
</NavLink>
))}
</nav>
{/* 全局行情开关 */}
<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>
</div>
) : (
/* Starter+ — 开关 + 跳转设置 */
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className={`inline-block h-1.5 w-1.5 rounded-full shrink-0 ${
realtimeEnabled && isRunning && isTrading
? 'bg-accent animate-pulse'
: realtimeEnabled
? 'bg-warning/60'
: 'bg-muted'
}`} />
<span className="text-xs text-secondary truncate">
</span>
<button
onClick={() => navigate('/settings?tab=monitoring')}
className="text-secondary hover:text-foreground transition-colors shrink-0"
title="实时监控设置"
>
<Timer className="h-3 w-3" />
</button>
</div>
<button
onClick={() => handleToggle(!realtimeEnabled)}
disabled={toggleQuote.isPending}
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
realtimeEnabled
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
: 'bg-elevated'
} ${toggleQuote.isPending ? 'opacity-50' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
realtimeEnabled ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
)}
{/* 状态提示 */}
{realtimeEnabled && !isFreeTier && (
<div className="mt-1.5 text-[10px] leading-snug">
{isRunning && isTrading ? (
<span className="text-accent"></span>
) : realtimeEnabled && !isTrading ? (
<span className="text-warning/70"></span>
) : null}
</div>
)}
{showSidebarQuotes && !isFreeTier && (
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
)}
</div>
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0">
<NavLink
to="/settings"
className={({ isActive }) =>
cn(
'flex items-center justify-between gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
isActive
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
)
}
>
<span className="flex items-center gap-3">
<Settings className="h-4 w-4 shrink-0" />
<span></span>
</span>
<span className="font-mono text-[10px] text-muted/50 select-none">
{version ?? ''}
</span>
</NavLink>
</div>
</aside>
<motion.main
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="h-full overflow-auto scrollbar-gutter-stable"
>
<Outlet />
</motion.main>
<ToastContainer />
<AlertToastContainer />
</div>
)
}
@@ -0,0 +1,757 @@
/**
* 通用列表列自定义组件。
*
* 布局:
* - 上半区「已启用」:已启用的列,@dnd-kit 拖拽排序
* - 下半区「内置列」:按业务分组折叠
* - 底部「扩展数据列」:复用 ext_data schema,按需添加字段
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
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 { X, GripVertical, Plus, ChevronDown, ChevronRight, Database, Settings2, Search, Eye, EyeOff } from 'lucide-react'
import { api } from '@/lib/api'
import { useQuery } from '@tanstack/react-query'
import { QK } from '@/lib/queryKeys'
import type { ColumnConfig, ColumnGroup, ExtColumnDisplayConfig, CandleColumnConfig } from '@/lib/list-columns'
import { resolveCandleConfig } from '@/lib/list-columns'
interface ListColumnCustomizerProps {
columns: ColumnConfig[]
groups: ColumnGroup[]
onChange: (columns: ColumnConfig[]) => void
open: boolean
onClose: () => void
title?: string
builtinSectionLabel?: string
extColumnAlign?: 'left' | 'center' | 'right'
extFieldFilter?: (field: { name: string; label: string; type: string }) => boolean
/** 是否显示扩展数据列区块(默认 true;信息条等无法渲染 ext 数据的场景设为 false)。 */
showExtColumns?: boolean
/** 是否显示「单独显示」勾选项(默认 false;仅信息条场景启用,让某列独占一行)。 */
showStandaloneToggle?: boolean
}
function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: {
col: ColumnConfig
onRemove: (id: string) => void
onConfig: (id: string | null) => void
configOpen: boolean
extTableLabel: string
extConfig: React.ReactNode
candleConfig: React.ReactNode
strategiesConfig: React.ReactNode
showStandaloneToggle?: boolean
onToggleStandalone?: (id: string) => void
}) {
const {
attributes, listeners, setNodeRef, transform, transition, isDragging,
} = useSortable({ id: col.id })
const isExt = col.source.type === 'ext'
const isCandle = col.source.type === 'builtin' && col.source.key === 'candle'
const isStrategies = col.source.type === 'builtin' && col.source.key === 'strategies'
const hasConfig = isExt || isCandle || isStrategies
return (
<>
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}}
className={`flex items-center gap-2 px-2 py-1.5 rounded group ${
isDragging ? 'bg-elevated shadow-lg' : 'hover:bg-elevated/50'
}`}
>
<span
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors shrink-0"
>
<GripVertical className="h-3.5 w-3.5" />
</span>
<span className="flex-1 text-xs text-foreground truncate">
{isExt && col.source.type === 'ext'
? `${col.label}${extTableLabel}`
: col.label}
</span>
{showStandaloneToggle && (
<button
onClick={() => onToggleStandalone?.(col.id)}
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors shrink-0 ${
col.standalone
? 'text-accent bg-accent/10'
: 'text-muted hover:text-secondary opacity-0 group-hover:opacity-100'
}`}
title={col.standalone ? '取消单独显示' : '单独一行显示'}
>
{col.standalone ? '单独' : '单行'}
</button>
)}
{hasConfig && (
<button
onClick={() => onConfig(configOpen ? null : col.id)}
className={`transition-all ${configOpen ? 'text-accent' : 'opacity-0 group-hover:opacity-100 text-muted hover:text-accent'}`}
title="配置"
>
<Settings2 className="h-3 w-3" />
</button>
)}
<button
onClick={() => onRemove(col.id)}
className="opacity-0 group-hover:opacity-100 text-muted hover:text-danger transition-all shrink-0"
title="隐藏"
>
<EyeOff className="h-3 w-3" />
</button>
</div>
{hasConfig && configOpen && (isExt ? extConfig : isCandle ? candlePanel : strategiesConfig)}
</>
)
}
export function ListColumnCustomizer({
columns,
groups,
onChange,
open,
onClose,
title = '自定义列',
builtinSectionLabel = '内置列',
extColumnAlign = 'center',
extFieldFilter,
showExtColumns = true,
showStandaloneToggle = false,
}: ListColumnCustomizerProps) {
const extSchema = useQuery({
queryKey: QK.extDataSchemaAll,
queryFn: api.extDataSchemaAll,
enabled: open && showExtColumns,
staleTime: 60_000,
})
const [searchQuery, setSearchQuery] = useState('')
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const [configOpenId, setConfigOpenId] = useState<string | null>(null)
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set())
const colById = useMemo(() => new Map(columns.map(c => [c.id, c])), [columns])
const keyToId = useMemo(() => {
const m = new Map<string, string>()
for (const c of columns) {
if (c.source.type === 'builtin' || c.source.type === 'computed') m.set(c.source.key, c.id)
}
return m
}, [columns])
const activeCols = useMemo(() => columns.filter(c => !c.pinned && c.visible), [columns])
const toggleVisible = useCallback((colId: string) => {
onChange(columns.map(c =>
c.id === colId && !c.pinned ? { ...c, visible: !c.visible } : c
))
}, [columns, onChange])
const toggleStandalone = useCallback((colId: string) => {
onChange(columns.map(c =>
c.id === colId ? { ...c, standalone: !c.standalone } : c
))
}, [columns, onChange])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const reorderCols = columns.filter(c => !c.pinned)
const pinnedCols = columns.filter(c => c.pinned)
const ids = reorderCols.map(c => c.id)
const oldIdx = ids.indexOf(active.id as string)
const newIdx = ids.indexOf(over.id as string)
if (oldIdx < 0 || newIdx < 0) return
const reordered = arrayMove(reorderCols, oldIdx, newIdx)
onChange([...pinnedCols, ...reordered])
}, [columns, onChange])
const addExtColumn = useCallback((configId: string, fieldName: string, fieldLabel?: string) => {
const colId = `ext:${configId}:${fieldName}`
if (columns.some(c => c.id === colId)) {
toggleVisible(colId)
return
}
const newCol: ColumnConfig = {
id: colId,
source: { type: 'ext', configId, fieldName, fieldLabel },
label: fieldLabel || fieldName,
visible: true,
align: extColumnAlign,
}
const actionIdx = columns.findIndex(c => c.id === 'builtin:action')
if (actionIdx >= 0) {
const next = [...columns]
next.splice(actionIdx, 0, newCol)
onChange(next)
} else {
onChange([...columns, newCol])
}
}, [columns, extColumnAlign, onChange, toggleVisible])
const hideColumn = useCallback((colId: string) => {
onChange(columns.map(c => c.id === colId ? { ...c, visible: false } : c))
}, [columns, onChange])
const updateExtDisplay = useCallback((colId: string, patch: Partial<ExtColumnDisplayConfig>) => {
onChange(columns.map(c => {
if (c.id !== colId) return c
return { ...c, extDisplay: { displayMode: 'tag', ...(c.extDisplay || {}), ...patch } }
}))
}, [columns, onChange])
const resetExtDisplay = useCallback((colId: string) => {
onChange(columns.map(c => {
if (c.id !== colId) return c
const { extDisplay, ...rest } = c
return rest
}))
}, [columns, onChange])
const updateCandleConfig = useCallback((colId: string, patch: Partial<CandleColumnConfig>) => {
onChange(columns.map(c => {
if (c.id !== colId) return c
return { ...c, candleConfig: { ...c.candleConfig, ...patch } }
}))
}, [columns, onChange])
const resetCandleConfig = useCallback((colId: string) => {
onChange(columns.map(c => {
if (c.id !== colId) return c
const { candleConfig, ...rest } = c
return rest
}))
}, [columns, onChange])
const toggleGroup = useCallback((groupId: string) => {
setExpandedGroups(prev => {
const next = new Set(prev)
if (next.has(groupId)) next.delete(groupId)
else next.add(groupId)
return next
})
}, [])
const toggleTableExpand = useCallback((tableId: string) => {
setExpandedTables(prev => {
const next = new Set(prev)
if (next.has(tableId)) next.delete(tableId)
else next.add(tableId)
return next
})
}, [])
const extTables = extSchema.data?.items ?? []
const extTableLabelMap = new Map(extTables.map(t => [t.id, t.label]))
const query = searchQuery.trim().toLowerCase()
const filteredGroups = useMemo(() => {
if (!query) return groups
return groups.filter(g =>
g.label.includes(query) ||
g.keys.some(k => {
const id = keyToId.get(k)
return id && (colById.get(id)?.label ?? '').toLowerCase().includes(query)
})
)
}, [groups, query, keyToId, colById])
useEffect(() => {
if (!open) { setConfigOpenId(null); setSearchQuery('') }
}, [open])
useEffect(() => {
if (query) setExpandedGroups(new Set(filteredGroups.map(g => g.id)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
const renderExtConfig = (col: ColumnConfig) => (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="pl-10 pr-3 py-2 space-y-2 border-l-2 border-accent/20 ml-[18px]">
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<select
value={col.extDisplay?.displayMode ?? 'tag'}
onChange={e => updateExtDisplay(col.id, { displayMode: e.target.value as 'tag' | 'text' })}
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 focus:outline-none focus:border-accent/50"
>
<option value="tag"></option>
<option value="text"></option>
</select>
</label>
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<input
type="text"
value={col.extDisplay?.separator ?? ''}
onChange={e => updateExtDisplay(col.id, { separator: e.target.value })}
placeholder="默认:、,;-"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
)}
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<input
type="text"
value={col.extDisplay?.maxWidth ?? ''}
onChange={e => updateExtDisplay(col.id, { maxWidth: e.target.value })}
placeholder="如 200px"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">N个</span>
<input
type="number" min={0}
value={col.extDisplay?.maxTags ?? ''}
onChange={e => {
const v = e.target.value ? Number(e.target.value) : undefined
updateExtDisplay(col.id, { maxTags: v, ...(v ? {} : { hiddenIndices: undefined }) })
}}
placeholder="0=全部"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
)}
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (col.extDisplay?.maxTags ?? 0) > 0 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<div className="flex flex-wrap gap-1">
{Array.from({ length: col.extDisplay!.maxTags! }, (_, i) => {
const hidden = col.extDisplay?.hiddenIndices?.includes(i)
return (
<button
key={i}
onClick={() => {
const cur = col.extDisplay?.hiddenIndices ?? []
const next = hidden ? cur.filter(x => x !== i) : [...cur, i]
updateExtDisplay(col.id, { hiddenIndices: next.length ? next : undefined })
}}
className={`w-6 h-6 rounded text-[10px] font-medium transition-colors ${
hidden ? 'bg-elevated text-muted line-through' : 'bg-accent/15 text-accent'
}`}
>
{i + 1}
</button>
)
})}
</div>
</div>
)}
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<div className="flex rounded overflow-hidden border border-border">
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'horizontal' })}
className={`px-3 py-1 text-xs transition-colors ${
(col.extDisplay?.tagLayout ?? 'horizontal') === 'horizontal'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
></button>
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'vertical' })}
className={`px-3 py-1 text-xs transition-colors border-l border-border ${
col.extDisplay?.tagLayout === 'vertical'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
></button>
</div>
</label>
)}
{col.extDisplay && (
<div className="flex justify-end pt-1">
<button onClick={() => resetExtDisplay(col.id)} className="text-[10px] text-muted hover:text-foreground transition-colors">
</button>
</div>
)}
</div>
</motion.div>
)
// 策略列配置(精简版:仅显示数量/位置/排列方向,复用 extDisplay 存储)
const renderStrategiesConfig = (col: ColumnConfig) => (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="pl-10 pr-3 py-2 space-y-2 border-l-2 border-accent/20 ml-[18px]">
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">N个</span>
<input
type="number" min={0}
value={col.extDisplay?.maxTags ?? ''}
onChange={e => {
const v = e.target.value ? Number(e.target.value) : undefined
updateExtDisplay(col.id, { maxTags: v, ...(v ? {} : { hiddenIndices: undefined }) })
}}
placeholder="0=全部"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
{(col.extDisplay?.maxTags ?? 0) > 0 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<div className="flex flex-wrap gap-1">
{Array.from({ length: col.extDisplay!.maxTags! }, (_, i) => {
const hidden = col.extDisplay?.hiddenIndices?.includes(i)
return (
<button
key={i}
onClick={() => {
const cur = col.extDisplay?.hiddenIndices ?? []
const next = hidden ? cur.filter(x => x !== i) : [...cur, i]
updateExtDisplay(col.id, { hiddenIndices: next.length ? next : undefined })
}}
className={`w-6 h-6 rounded text-[10px] font-medium transition-colors ${
hidden ? 'bg-elevated text-muted line-through' : 'bg-accent/15 text-accent'
}`}
>
{i + 1}
</button>
)
})}
</div>
</div>
)}
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<div className="flex rounded overflow-hidden border border-border">
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'horizontal' })}
className={`px-3 py-1 text-xs transition-colors ${
(col.extDisplay?.tagLayout ?? 'horizontal') === 'horizontal'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
></button>
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'vertical' })}
className={`px-3 py-1 text-xs transition-colors border-l border-border ${
col.extDisplay?.tagLayout === 'vertical'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
></button>
</div>
</label>
{col.extDisplay && (
<div className="flex justify-end pt-1">
<button onClick={() => resetExtDisplay(col.id)} className="text-[10px] text-muted hover:text-foreground transition-colors">
</button>
</div>
)}
</div>
</motion.div>
)
const renderCandleConfig = (col: ColumnConfig) => {
const cfg = resolveCandleConfig(col.candleConfig)
// 数值输入:空字符串回退到默认值,便于清空重输
const numInput = (
field: keyof CandleColumnConfig,
label: string,
) => (
<label key={field} className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">{label}</span>
<input
type="number"
value={cfg[field]}
onChange={e => {
const raw = e.target.value
// 调用 resolveCandleConfig 钳制:过大取上限、过小取最小值
const merged = resolveCandleConfig({ ...col.candleConfig, [field]: raw === '' ? undefined : Number(raw) })
updateCandleConfig(col.id, { [field]: merged[field] })
}}
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 focus:outline-none focus:border-accent/50 tabular-nums"
/>
</label>
)
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="pl-10 pr-3 py-2 space-y-2 border-l-2 border-accent/20 ml-[18px]">
{numInput('days', '日k天数')}
{numInput('enabledWidth', '开启宽度')}
{numInput('enabledHeight', '开启高度')}
{numInput('disabledWidth', '收起宽度')}
{numInput('disabledHeight', '收起高度')}
<div className="text-[10px] text-muted leading-relaxed pt-0.5">
40300 / 32200 / 160
</div>
{col.candleConfig && (
<div className="flex justify-end pt-1">
<button onClick={() => resetCandleConfig(col.id)} className="text-[10px] text-muted hover:text-foreground transition-colors">
</button>
</div>
)}
</div>
</motion.div>
)
}
const renderCheckbox = (checked: boolean) => (
<span className={`shrink-0 w-4 h-4 rounded flex items-center justify-center transition-colors ${
checked ? 'bg-accent text-white' : 'border border-border text-transparent group-hover:border-muted'
}`}>
<svg viewBox="0 0 16 16" className="w-2.5 h-2.5" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8.5L6.5 12L13 4" />
</svg>
</span>
)
const renderBuiltinRow = (col: ColumnConfig) => (
<div
key={col.id}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-elevated/50 text-left group transition-colors"
>
<button onClick={() => toggleVisible(col.id)} className="flex items-center gap-2 flex-1 min-w-0">
{renderCheckbox(col.visible)}
<span className={`flex-1 text-xs truncate ${col.visible ? 'text-foreground' : 'text-muted'}`}>{col.label}</span>
</button>
{showStandaloneToggle && col.visible && (
<button
onClick={() => toggleStandalone(col.id)}
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors shrink-0 ${
col.standalone
? 'text-accent bg-accent/10'
: 'text-muted hover:text-secondary'
}`}
title={col.standalone ? '取消单独显示' : '单独一行显示'}
>
{col.standalone ? '单独' : '单行'}
</button>
)}
</div>
)
const renderExtFieldRow = (configId: string, field: { name: string; label: string; type: string }) => {
const colId = `ext:${configId}:${field.name}`
const existing = columns.find(c => c.id === colId)
const checked = !!existing?.visible
return (
<button
key={field.name}
onClick={() => addExtColumn(configId, field.name, field.label)}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-elevated/50 text-left group transition-colors"
>
{renderCheckbox(checked)}
<span className={`flex-1 text-xs truncate ${checked ? 'text-foreground' : 'text-muted'}`}>{field.label || field.name}</span>
<span className="text-[10px] text-muted shrink-0">{field.type}</span>
</button>
)
}
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex justify-end">
<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={onClose}
/>
<motion.div
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[360px] max-w-[90vw] h-full bg-base border-l border-border shadow-2xl flex flex-col"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated text-muted hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-3 pt-3 pb-1">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索列名..."
className="w-full h-8 pl-8 pr-3 rounded-lg bg-elevated border border-border text-xs text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 transition-colors"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{activeCols.length > 0 && (
<div>
<div className="flex items-center gap-1.5 px-1 py-1.5">
<Eye className="h-3 w-3 text-accent/70" />
<span className="text-[10px] font-semibold text-accent/80 uppercase tracking-wider"></span>
<span className="text-[10px] text-muted">{activeCols.length} · </span>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={activeCols.map(c => c.id)} strategy={verticalListSortingStrategy}>
{activeCols.map(col => (
<SortableActiveCol
key={col.id}
col={col}
onRemove={hideColumn}
onConfig={setConfigOpenId}
configOpen={configOpenId === col.id}
extTableLabel={col.source.type === 'ext' ? (extTableLabelMap.get(col.source.configId) || col.source.configId) : ''}
extConfig={renderExtConfig(col)}
candleConfig={renderCandleConfig(col)}
strategiesConfig={renderStrategiesConfig(col)}
showStandaloneToggle={showStandaloneToggle}
onToggleStandalone={toggleStandalone}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
<div className="border-t border-border my-1" />
<div>
<div className="flex items-center gap-1.5 px-1 py-1.5">
<Plus className="h-3 w-3 text-muted" />
<span className="text-[10px] font-semibold text-muted uppercase tracking-wider">{builtinSectionLabel}</span>
</div>
{filteredGroups.map(group => {
const isExpanded = expandedGroups.has(group.id)
const groupCols = group.keys
.map(k => keyToId.get(k))
.filter((id): id is string => !!id)
.map(id => colById.get(id))
.filter((c): c is ColumnConfig => !!c)
if (groupCols.length === 0) return null
const visCount = groupCols.filter(c => c.visible).length
return (
<div key={group.id}>
<button
onClick={() => toggleGroup(group.id)}
className="flex items-center gap-1.5 w-full px-1 py-1.5 rounded hover:bg-elevated/30 text-left transition-colors"
>
{isExpanded ? <ChevronDown className="h-3 w-3 text-muted" /> : <ChevronRight className="h-3 w-3 text-muted" />}
{group.icon && <span className="text-[11px]">{group.icon}</span>}
<span className="text-[11px] font-medium text-secondary">{group.label}</span>
<span className="text-[10px] text-muted ml-auto">{visCount}/{groupCols.length}</span>
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.12 }}
className="overflow-hidden"
>
<div className="space-y-0.5 pb-0.5">
{groupCols.map(col => renderBuiltinRow(col))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
{showExtColumns && extTables.length > 0 && (
<div className="pt-1 border-t border-border mt-1">
<div className="flex items-center gap-1.5 px-1 py-1.5">
<Database className="h-3 w-3 text-accent/70" />
<span className="text-[10px] font-semibold text-accent/80 uppercase tracking-wider"></span>
</div>
<div className="space-y-0.5">
{extTables.map(table => {
const isExpanded = expandedTables.has(table.id)
const dataFields = table.columns
.filter(c => !['symbol', 'code', 'date'].includes(c.name))
.filter(c => extFieldFilter ? extFieldFilter(c) : true)
const activeCount = dataFields.filter(f => {
const colId = `ext:${table.id}:${f.name}`
return columns.some(c => c.id === colId && c.visible)
}).length
return (
<div key={table.id}>
<button
onClick={() => toggleTableExpand(table.id)}
className="flex items-center gap-1.5 w-full px-1 py-1.5 rounded hover:bg-elevated/30 text-left transition-colors"
>
{isExpanded ? <ChevronDown className="h-3 w-3 text-muted" /> : <ChevronRight className="h-3 w-3 text-muted" />}
<Database className="h-3 w-3 text-accent shrink-0" />
<span className="text-[11px] font-medium text-secondary flex-1">{table.label}</span>
<span className="text-[10px] text-muted">{table.mode === 'snapshot' ? '快照' : '时序'}</span>
{activeCount > 0 && (
<span className="text-[10px] text-accent font-medium">{activeCount}</span>
)}
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="space-y-0.5 pb-0.5">
{dataFields.map(field => renderExtFieldRow(table.id, field))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
</div>
)}
{showExtColumns && extTables.length === 0 && extSchema.isSuccess && (
<div className="text-xs text-muted text-center py-4">
</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
+59
View File
@@ -0,0 +1,59 @@
// 原创 logo:方括号 [ ] 包裹一根带 wick 的 K 线
//
// 概念:
// - 外层 brackets:终端 / 代码 / 引用边界 — 赛博 + quant 气质
// - 中央 wick+body:一根标准 K 线 — 直接的金融指代
// - body 偏上 + 下影长:bullish 站稳感 (上影短 / 下影长)
//
// 用 currentColor,继承父级 color 设定,方便切换品牌色。
interface LogoProps {
className?: string
size?: number
style?: React.CSSProperties
}
export function Logo({ className, size = 32, style }: LogoProps) {
return (
<svg
viewBox="0 0 32 32"
width={size}
height={size}
fill="none"
className={className}
style={style}
role="img"
aria-label="Stock Panel"
>
{/* 左方括号 */}
<path
d="M10 4 L4 4 L4 28 L10 28"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="miter"
strokeLinecap="butt"
/>
{/* 右方括号 */}
<path
d="M22 4 L28 4 L28 28 L22 28"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="miter"
strokeLinecap="butt"
/>
{/* K 线 wick(上下影线,半透明) */}
<line
x1="16" y1="7" x2="16" y2="25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeOpacity="0.6"
/>
{/* K 线 body — 偏上,上影短/下影长, bullish 站稳感 */}
<rect
x="13" y="9" width="6" height="10"
fill="currentColor"
rx="0.5"
/>
</svg>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { cn } from '@/lib/cn'
interface Props {
title: string
subtitle?: string
/** 标题右侧、subtitle 之前的额外节点(如状态徽标) */
titleExtra?: React.ReactNode
right?: React.ReactNode
className?: string
}
export function PageHeader({ title, subtitle, titleExtra, right, className }: Props) {
return (
<header
className={cn(
'px-5 pt-3 pb-2 border-b border-border flex items-center justify-between gap-4',
className,
)}
>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold tracking-tight">{title}</h1>
{titleExtra}
{subtitle && <span className="text-xs text-muted">{subtitle}</span>}
</div>
{right}
</header>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { HelpCircle } from 'lucide-react'
import { api } from '@/lib/api'
import { toast } from '@/components/Toast'
/** 单方向(涨停/跌停)的修正明细块 */
function SealedDirBlock({ title, color, counts, rawTotal }: {
title: string
color: 'bull' | 'bear'
counts?: { real: number; fake: number; pending: number }
rawTotal?: number
}) {
const real = counts?.real ?? 0
const fake = counts?.fake ?? 0
// pending 从原始总数推算(后端 pending 含另一方向票, 不可用)
const pending = Math.max(0, (rawTotal ?? 0) - real - fake)
const original = rawTotal ?? (real + fake + pending)
const fixed = real + pending
return (
<div className="mb-2 last:mb-0">
<div className={`flex items-center justify-between px-1 py-0.5 rounded bg-${color}/5 mb-1`}>
<span className={`text-[10px] font-medium text-${color}`}>{title}</span>
<span className="tabular-nums text-[10px]">
<span className="text-muted line-through">{original}</span>
<span className="text-muted/50 mx-1"></span>
<span className={`font-bold text-${color}`}>{fixed}</span>
</span>
</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>
{pending > 0 && (
<span className="flex items-center gap-0.5 text-muted"><span className="h-1 w-1 rounded-full bg-muted" /> {pending}</span>
)}
</div>
</div>
)
}
/** 修正/降级 标识 + 问号弹窗(连板梯队/看板共用) */
export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sealedCountsUp, sealedCountsDown, rawUp, rawDown, invalidateKeys = ['limit-ladder'] }: {
degraded: boolean
hasDepth: boolean
isHistorical: boolean
sealedReady: boolean | undefined
sealedCountsUp?: { real: number; fake: number; pending: number }
sealedCountsDown?: { real: number; fake: number; pending: number }
rawUp?: number
rawDown?: number
/** 修正后要刷新的 queryKey 前缀(默认连板梯队) */
invalidateKeys?: string[]
}) {
const [showHint, setShowHint] = useState(false)
const navigate = useNavigate()
const qc = useQueryClient()
const runFix = useMutation({
mutationFn: () => api.runLimitLadderFix(),
onSuccess: (data) => {
toast(data.msg, data.ok ? 'success' : 'error')
if (data.ok) invalidateKeys.forEach(k => qc.invalidateQueries({ queryKey: [k] }))
},
onError: () => toast('修正请求失败', 'error'),
})
// 组装原因文案(仅降级时用)
const reasons: string[] = []
if (!hasDepth) reasons.push('当前套餐无五档盘口能力(需 Pro+),涨停判定基于收盘价,可能含假涨停')
if (isHistorical) reasons.push('历史日期的盘口快照不可获取,无法判定真假板')
if (hasDepth && !isHistorical && !sealedReady) reasons.push('盘中 sealed 数据尚未就绪,收盘后自动恢复')
const label = degraded ? '降级' : '修正'
return (
<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"
>
<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" />
</button>
<AnimatePresence>
{showHint && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowHint(false)} />
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.95 }}
className="absolute top-full left-0 mt-1 z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed"
onClick={e => e.stopPropagation()}
>
{degraded ? (
<>
<div className="font-medium text-foreground mb-1.5"></div>
{reasons.map((r, i) => (
<div key={i} className="flex gap-1 mb-1">
<span className="text-yellow-500 shrink-0">·</span>
<span>{r}</span>
</div>
))}
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
(/)Pro+
</div>
</>
) : (
<>
<div className="font-medium text-foreground mb-1.5"></div>
<SealedDirBlock title="涨停" color="bull" counts={sealedCountsUp} rawTotal={rawUp} />
<SealedDirBlock title="跌停" color="bear" counts={sealedCountsDown} rawTotal={rawDown} />
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
,//{sealedReady && '数据为盘中快照,收盘后自动定版。'}
</div>
</>
)}
<div className="mt-2 flex gap-1.5">
{hasDepth && !isHistorical && (
<button
onClick={() => { runFix.mutate(); setShowHint(false) }}
disabled={runFix.isPending}
className="flex-1 px-2 py-1.5 rounded text-[11px] bg-accent/15 text-accent hover:bg-accent/25 transition-colors text-center disabled:opacity-50"
>
{runFix.isPending ? '修正中…' : '立即修正'}
</button>
)}
<button
onClick={() => { setShowHint(false); navigate('/settings?tab=monitoring&highlight=depth-fix') }}
className={`${hasDepth && !isHistorical ? '' : 'w-full'} px-2 py-1.5 rounded text-[11px] bg-elevated text-secondary hover:text-foreground transition-colors text-center`}
>
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,236 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api, type KlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import {
EChartsCandlestick,
OVERLAY_INDICATORS,
SUB_CHARTS,
type ChartMarker,
type ChartPriceLine,
type ChartRange,
type OHLC,
type StockInfo,
} from '@/components/EChartsCandlestick'
const SUB_INFO_H = 16
const SUB_GAP = 4
const MAX_DAYS = 2000
export interface StockDailyKChartResult {
rows: OHLC[]
rawRows: KlineRow[]
stockInfo?: StockInfo
name?: string
}
interface Props {
symbol: string
height?: number
className?: string
dateRange?: { start: string; end: string }
markers?: ChartMarker[]
ranges?: ChartRange[]
priceLines?: ChartPriceLine[]
showLimitMarkers?: boolean
showIndicatorControls?: boolean
showMarkerToggle?: boolean
showMA?: boolean
showInfoBar?: boolean
visibleBars?: number
linkedPrice?: number | null
onDateClick?: (date: string) => void
onDataChange?: (result: StockDailyKChartResult) => void
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
extColumns?: string
}
function isValidRow(r: any): boolean {
return r && r.date != null && r.open != null && r.close != null
}
export function toOHLC(rows: KlineRow[]): OHLC[] {
return rows
.filter(isValidRow)
.map(r => ({
date: typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date),
open: Number(r.open),
high: Number(r.high),
low: Number(r.low),
close: Number(r.close),
volume: Number(r.volume ?? 0),
ma5: r.ma5 != null ? Number(r.ma5) : null,
ma10: r.ma10 != null ? Number(r.ma10) : null,
ma20: r.ma20 != null ? Number(r.ma20) : null,
ma60: r.ma60 != null ? Number(r.ma60) : null,
macd_dif: r.macd_dif != null ? Number(r.macd_dif) : null,
macd_dea: r.macd_dea != null ? Number(r.macd_dea) : null,
macd_hist: r.macd_hist != null ? Number(r.macd_hist) : null,
rsi_6: r.rsi_6 != null ? Number(r.rsi_6) : null,
rsi_14: r.rsi_14 != null ? Number(r.rsi_14) : null,
rsi_24: r.rsi_24 != null ? Number(r.rsi_24) : null,
kdj_k: r.kdj_k != null ? Number(r.kdj_k) : null,
kdj_d: r.kdj_d != null ? Number(r.kdj_d) : null,
kdj_j: r.kdj_j != null ? Number(r.kdj_j) : null,
boll_upper: r.boll_upper != null ? Number(r.boll_upper) : null,
boll_lower: r.boll_lower != null ? Number(r.boll_lower) : null,
}))
}
function buildLimitUpMarkers(rows: KlineRow[]): ChartMarker[] {
const markers: ChartMarker[] = []
for (const r of rows) {
const date = typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date)
if (r.signal_broken_limit_up) {
markers.push({ date, kind: 'neutral', above: true, color: '#8B5CF6', label: '炸' })
} else if (r.signal_limit_up) {
const boards: number = r.consecutive_limit_ups ?? 1
markers.push({ date, kind: 'buy', above: true, color: '#FACC15', label: boards <= 1 ? '板' : String(boards) })
}
}
return markers
}
export function getDefaultRange(): { start: string; end: string } {
const now = new Date()
const end = now.toISOString().slice(0, 10)
const s = new Date(now)
s.setMonth(s.getMonth() - 6)
const start = s.toISOString().slice(0, 10)
return { start, end }
}
function rangeDays(range: { start: string; end: string }): number {
const start = new Date(range.start)
const end = new Date(range.end)
return Math.min(Math.ceil((end.getTime() - start.getTime()) / 86400000) + 30, MAX_DAYS)
}
export function StockDailyKChart({
symbol,
height = 520,
className,
dateRange: externalDateRange,
markers,
ranges,
priceLines,
showLimitMarkers = true,
showIndicatorControls = true,
showMarkerToggle = true,
showMA = true,
showInfoBar = true,
visibleBars = 60,
linkedPrice,
onDateClick,
onDataChange,
extColumns,
}: Props) {
const [activeIndicators, setActiveIndicators] = useState<string[]>(['vol'])
const [showMarkers, setShowMarkers] = useState(true)
const dateRange = externalDateRange ?? getDefaultRange()
const days = useMemo(() => rangeDays(dateRange), [dateRange])
// extColumns 纳入 query key:勾选/取消扩展字段时需重新请求(带 ext_columns 参数)
const kline = useQuery({
queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns),
queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns),
enabled: !!symbol,
placeholderData: (prev) => prev,
})
const rows = useMemo(() => toOHLC(kline.data?.rows ?? []), [kline.data?.rows])
const stockInfo = kline.data?.stock_info
const limitMarkers = useMemo(() => buildLimitUpMarkers(kline.data?.rows ?? []), [kline.data?.rows])
const allMarkers = useMemo(() => [
...(markers ?? []),
...(showLimitMarkers ? limitMarkers : []),
], [limitMarkers, markers, showLimitMarkers])
const toggleIndicator = useCallback((key: string) => {
setActiveIndicators(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])
}, [])
const activeSubDefs = activeIndicators
.map(key => SUB_CHARTS.find(s => s.key === key))
.filter((d): d is typeof SUB_CHARTS[number] => !!d)
let subExtraH = 0
activeSubDefs.forEach(def => { subExtraH += SUB_INFO_H + def.height })
if (activeSubDefs.length > 0) subExtraH += activeSubDefs.length * SUB_GAP + 14
const chartHeight = height + subExtraH
useEffect(() => {
onDataChange?.({ rows, rawRows: kline.data?.rows ?? [], stockInfo, name: kline.data?.name })
}, [kline.data?.name, kline.data?.rows, onDataChange, rows, stockInfo])
if (!symbol) return null
return (
<div className={className} style={{ minHeight: chartHeight }}>
{showIndicatorControls && rows.length > 0 && (
<div className="flex items-center gap-1.5 px-1 pb-0.5">
{SUB_CHARTS.map(ind => (
<button
key={ind.key}
onClick={() => toggleIndicator(ind.key)}
className={`px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
activeIndicators.includes(ind.key)
? 'bg-accent/20 text-accent'
: 'bg-elevated text-muted hover:text-secondary'
}`}
>
{ind.label}
</button>
))}
{OVERLAY_INDICATORS.map(ind => (
<button
key={ind.key}
onClick={() => toggleIndicator(ind.key)}
className={`px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
activeIndicators.includes(ind.key)
? 'bg-accent/20 text-accent'
: 'bg-elevated text-muted hover:text-secondary'
}`}
>
{ind.label}
</button>
))}
{showMarkerToggle && showLimitMarkers && (
<button
onClick={() => setShowMarkers(v => !v)}
className={`ml-auto px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
showMarkers
? 'text-[#FACC15] bg-[#FACC15]/10'
: 'bg-elevated text-muted hover:text-secondary'
}`}
>
</button>
)}
</div>
)}
{kline.isLoading && <div className="text-sm text-muted py-4"></div>}
{kline.isError && <div className="text-sm text-danger py-2">K加载失败</div>}
{!kline.isLoading && !kline.isError && (kline.data?.rows?.length ?? 0) > 0 && rows.length === 0 && (
<div className="text-sm text-danger py-2"></div>
)}
{rows.length > 0 && (
<EChartsCandlestick
data={rows}
markers={allMarkers}
ranges={ranges}
priceLines={priceLines}
height={chartHeight - 22}
showMA={showMA}
showInfoBar={showInfoBar}
showMarkers={showMarkers}
stockInfo={stockInfo}
symbol={symbol}
linkedPrice={linkedPrice}
onDateClick={onDateClick}
visibleBars={visibleBars}
activeIndicators={activeIndicators}
/>
)}
</div>
)
}
+278
View File
@@ -0,0 +1,278 @@
import { useState, type ReactNode } from 'react'
import { Settings2, RadioTower, Star } from 'lucide-react'
import type { KlineRow, FinancialMetricRecord } from '@/lib/api'
import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format'
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields'
const BULL = '#C74040'
const BEAR = '#2D9B65'
interface Props {
symbol: string
name?: string
stockInfo?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record<string, unknown> }
rows: KlineRow[]
/** 信息条字段配置(由 StockPanel 提升,受控) */
fields: ColumnConfig[]
onFieldsChange: (fields: ColumnConfig[]) => void
/** 财务指标最新一期(来自 useFinancialMetrics,受 Cap.FINANCIAL 门控) */
financialMetrics?: FinancialMetricRecord
/** 加监控回调 (个股弹窗传入, 有值时渲染 RadioTower 图标) */
onMonitor?: () => void
/** 加自选回调 + 是否已自选 (有 onToggle 时渲染 Star 图标) */
inWatchlist?: boolean
onToggleWatchlist?: () => void
}
/**
* 精简渲染扩展数据值(信息条专用)。
* 仍尊重 extDisplay 配置:text=纯文本,tag(默认)=按分隔符拆成小标签 + maxTags 截断。
* 与自选列表的差异:标签模式无 maxWidth/排列方向,但保留 +N 展开交互。
*/
function renderExtInline(
val: unknown,
col: ColumnConfig,
expanded: boolean,
onToggle: () => void,
): ReactNode {
if (val == null || (typeof val === 'number' && Number.isNaN(val))) {
return <span className="text-muted"></span>
}
if (typeof val === 'number') {
const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val)
return <span className="tabular-nums">{displayVal}</span>
}
if (typeof val === 'boolean') {
return <span className={val ? 'text-bull' : 'text-muted'}>{val ? '是' : '否'}</span>
}
const str = String(val)
// 纯文本模式
if (col.extDisplay?.displayMode === 'text') {
return <span>{str}</span>
}
// 标签模式(默认):按分隔符拆成小标签
const sep = col.extDisplay?.separator?.trim() || null
const tags = sep
? str.split(sep).map(s => s.trim()).filter(Boolean)
: str.split(/[、,;\-]/).map(s => s.trim()).filter(Boolean)
if (tags.length === 0) return <span className="text-muted"></span>
// maxTags 截断 + 展开交互:收起时显示前 N 个 + +N,展开时显示全部 + 收起
const maxTags = col.extDisplay?.maxTags ?? 0
const hiddenIndices = maxTags > 0 ? col.extDisplay?.hiddenIndices : undefined
const showAll = maxTags <= 0 || expanded
const sliced = showAll ? tags : tags.slice(0, maxTags)
const shown = hiddenIndices?.length ? sliced.filter((_, i) => !hiddenIndices.includes(i)) : sliced
const overflow = tags.length - shown.length
return (
<span className="inline-flex flex-wrap items-center gap-0.5">
{shown.map((tag, i) => (
<span key={i} className="inline-block px-1 rounded text-[10px] leading-tight text-yellow-500 bg-yellow-500/10">
{tag}
</span>
))}
{!showAll && overflow > 0 && (
<button
onClick={onToggle}
className="inline-block px-1 rounded text-[10px] leading-tight text-accent bg-accent/10 hover:bg-accent/20 transition-colors"
>
+{overflow}
</button>
)}
{showAll && maxTags > 0 && tags.length > maxTags && (
<button
onClick={onToggle}
className="inline-block px-1 rounded text-[10px] leading-tight text-muted hover:text-foreground transition-colors"
>
</button>
)}
</span>
)
}
export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics, onMonitor, inWatchlist, onToggleWatchlist }: Props) {
// 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前
const [customizerOpen, setCustomizerOpen] = useState(false)
// ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰
const [expandedExt, setExpandedExt] = useState<Set<string>>(new Set())
const toggleExtExpand = (key: string) => {
setExpandedExt(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
if (rows.length === 0) return null
const latest = rows[rows.length - 1]
const prev = rows.length >= 2 ? rows[rows.length - 2] : null
const close = Number(latest.close)
const chg = prev ? close - Number(prev.close) : 0
const chgPct = prev ? chg / Number(prev.close) * 100 : 0
const isUp = chg >= 0
const clr = isUp ? BULL : BEAR
const totalShares = stockInfo?.total_shares
const floatShares = stockInfo?.float_shares
const marketCap = totalShares ? close * totalShares : null
const floatMarketCap = floatShares ? close * floatShares : null
const turnoverRate = floatShares && latest.volume
? (Number(latest.volume) * 100 / floatShares * 100)
: null
const displayName = stockInfo?.name ?? name ?? ''
const extData = stockInfo?.ext ?? {}
// 按指标 key 计算格式化值,无数据返回 null(渲染时跳过,与原行为一致)。
// 普通函数:依赖行情值每次 render 都变,useCallback 无收益;且必须定义在早期 return 之后。
const computeBuiltinValue = (key: string): string | null => {
switch (key) {
case 'market_cap': return marketCap != null ? fmtBigNum(marketCap) : null
case 'float_market_cap': return floatMarketCap != null ? fmtBigNum(floatMarketCap) : null
case 'turnover': return turnoverRate != null ? `${turnoverRate.toFixed(2)}%` : null
case 'volume': return latest.volume != null ? fmtVolume(Number(latest.volume)) : null
case 'amplitude': {
const prevClose = prev ? Number(prev.close) : null
if (prevClose == null || prevClose === 0) return null
const hi = Number(latest.high)
const lo = Number(latest.low)
return `${((hi - lo) / prevClose * 100).toFixed(2)}%`
}
case 'open': return fmtPrice(Number(latest.open))
case 'high': return fmtPrice(Number(latest.high))
case 'low': return fmtPrice(Number(latest.low))
// 财务指标:百分比字段存储为百分点(12.3 表示 12.3%),直接 toFixed(2) + %
case 'eps': return financialMetrics?.eps_basic != null ? fmtPrice(financialMetrics.eps_basic) : null
case 'bps': return financialMetrics?.bps != null ? fmtPrice(financialMetrics.bps) : null
case 'roe': return financialMetrics?.roe != null ? `${financialMetrics.roe.toFixed(2)}%` : null
case 'gross_margin':return financialMetrics?.gross_margin != null ? `${financialMetrics.gross_margin.toFixed(2)}%` : null
case 'net_margin': return financialMetrics?.net_margin != null ? `${financialMetrics.net_margin.toFixed(2)}%` : null
case 'debt_ratio': return financialMetrics?.debt_to_asset_ratio != null ? `${financialMetrics.debt_to_asset_ratio.toFixed(2)}%` : null
case 'revenue_yoy': return financialMetrics?.revenue_yoy != null ? `${financialMetrics.revenue_yoy.toFixed(2)}%` : null
case 'net_income_yoy': return financialMetrics?.net_income_yoy != null ? `${financialMetrics.net_income_yoy.toFixed(2)}%` : null
// PE/PB 后端无此字段,用现价现算(PE 基于最新一期 EPS,非严格 TTM)
case 'pe_ttm': {
const eps = financialMetrics?.eps_basic
return eps && eps !== 0 ? fmtPrice(close / eps) : null
}
case 'pb': {
const bps = financialMetrics?.bps
return bps && bps !== 0 ? fmtPrice(close / bps) : null
}
default: return null
}
}
const visibleFields = fields.filter(f => f.visible)
// 按是否单独显示分组:普通列共一行,standalone 列各占一行
const inlineFields = visibleFields.filter(f => !f.standalone)
const standaloneFields = visibleFields.filter(f => f.standalone)
// 渲染单个字段(builtin / ext 通用)
const renderField = (f: ColumnConfig): ReactNode => {
if (f.source.type === 'ext') {
const { configId, fieldName } = f.source
const val = extData[`${configId}__${fieldName}`]
// 无值的 ext 字段整体跳过(与 builtin 无数据行为一致)
if (val == null || (typeof val === 'number' && Number.isNaN(val))) return null
const cellKey = `${symbol}::${f.id}`
return (
<span key={f.id} className="inline-flex items-center gap-1">
<span>{f.label}</span>
<span className="text-secondary">
{renderExtInline(val, f, expandedExt.has(cellKey), () => toggleExtExpand(cellKey))}
</span>
</span>
)
}
// builtin
const value = computeBuiltinValue(f.source.type === 'builtin' ? f.source.key : '')
if (value == null) return null
return (
<span key={f.id}>
{f.label} <span className="text-secondary">{value}</span>
</span>
)
}
return (
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
{/* Row 1: code, name, price, change, change% */}
<div className="flex items-baseline gap-x-3 flex-wrap">
<span className="text-foreground font-bold text-sm tracking-wide">{symbol}</span>
<span className="text-secondary font-medium">{displayName}</span>
<span style={{ color: clr }} className="text-lg font-bold tabular-nums">
{fmtPrice(close)}
</span>
<span style={{ color: clr }} className="tabular-nums">
{isUp ? '+' : ''}{fmtPrice(chg)}
</span>
<span style={{ color: clr }} className="tabular-nums">
{isUp ? '+' : ''}{fmtPrice(chgPct)}%
</span>
{/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
<div className="ml-auto self-center flex items-center gap-1">
{onToggleWatchlist && (
<button
onClick={onToggleWatchlist}
className={`p-1 rounded-btn transition-colors cursor-pointer ${inWatchlist ? 'text-[#FACC15]' : 'text-muted hover:text-foreground hover:bg-elevated'}`}
title={inWatchlist ? '移出自选' : '加自选'}
>
<Star className="h-3.5 w-3.5" />
</button>
)}
{onMonitor && (
<button
onClick={onMonitor}
className="p-1 rounded-btn text-amber-400 hover:bg-amber-400/10 transition-colors cursor-pointer"
title="加监控"
>
<RadioTower className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => setCustomizerOpen(true)}
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
title="自定义信息条"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Row 2: 普通指标(builtin + ext,共一行 flex-wrap */}
{inlineFields.length > 0 && (
<div className="flex items-center gap-x-4 gap-y-1 text-[11px] flex-wrap text-muted">
{inlineFields.map(renderField)}
</div>
)}
{/* 单独显示的指标:各占一行 */}
{standaloneFields.map(f => {
const node = renderField(f)
if (node == null) return null
return (
<div key={f.id} className="flex items-center gap-x-4 text-[11px] flex-wrap text-muted">
{node}
</div>
)
})}
<ListColumnCustomizer
columns={fields}
groups={INFO_GROUPS}
onChange={onFieldsChange}
open={customizerOpen}
onClose={() => setCustomizerOpen(false)}
title="信息条指标"
builtinSectionLabel="可选指标"
extColumnAlign="left"
showStandaloneToggle
/>
</div>
)
}
@@ -0,0 +1,120 @@
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api, type MinuteKlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { EChartsIntraday } from '@/components/EChartsIntraday'
interface Props {
symbol: string
date: string | null
height?: number
prevClose?: number
className?: string
onPriceHover?: (price: number | null) => void
}
export function StockIntradayChart({
symbol,
date,
height = 520,
prevClose,
className,
onPriceHover,
}: Props) {
const qc = useQueryClient()
const [minuteDismissed, setMinuteDismissed] = useState(false)
const minute = useQuery({
queryKey: QK.klineMinute(symbol, date ?? ''),
queryFn: () => api.klineMinute(symbol, date ?? undefined),
enabled: !!symbol && !!date,
})
const fetchMinute = useMutation({
mutationFn: () => api.extendMinuteHistory(5, 'day'),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['kline-minute', symbol] })
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
setMinuteDismissed(false)
},
})
const minuteRows: MinuteKlineRow[] = useMemo(() => minute.data?.rows ?? [], [minute.data?.rows])
// source=none 表示本地无数据且数据源也拉不到 (停牌/复牌延迟/非交易日)
// 此时不弹"是否获取"询问窗, 只做静态提示, 避免误导用户去拉明知拉不到的数据
const sourceIsNone = minute.data?.source === 'none'
useEffect(() => {
setMinuteDismissed(false)
onPriceHover?.(null)
}, [date, onPriceHover])
if (!symbol || !date) return null
return (
<div className={className} style={{ height, flexShrink: 0 }}>
{minute.isLoading && <div className="text-xs text-muted py-2"></div>}
{!minute.isLoading && minuteRows.length === 0 && (
<>
{fetchMinute.isPending ? (
<div className="flex items-center justify-center h-full gap-2 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>5K</span>
</div>
) : sourceIsNone ? (
// 数据源确认无此日分钟数据 (停牌/复牌延迟等): 静态提示 + 保留重试
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="text-xs text-muted"></div>
<button
onClick={() => fetchMinute.mutate()}
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs font-medium hover:bg-elevated/80 transition-colors duration-150"
>
</button>
</div>
) : minuteDismissed ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="text-xs text-muted"></div>
<button
onClick={() => setMinuteDismissed(false)}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent transition-colors duration-150"
>
K
</button>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4">
<div className="text-sm text-foreground">5K</div>
<div className="flex items-center gap-3">
<button
onClick={() => fetchMinute.mutate()}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent transition-colors duration-150"
>
</button>
<button
onClick={() => setMinuteDismissed(true)}
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors duration-150"
>
</button>
</div>
</div>
)}
</>
)}
{minuteRows.length > 0 && (
<EChartsIntraday
data={minuteRows}
height={height}
prevClose={prevClose}
date={date}
symbol={symbol}
onPriceHover={onPriceHover}
/>
)}
</div>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { type KlineRow, type FinancialMetricRecord } from '@/lib/api'
import { StockInfoBar } from '@/components/StockInfoBar'
import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart'
import { StockIntradayChart } from '@/components/StockIntradayChart'
import { useFinancialMetrics } from '@/lib/useFinancials'
import { useCapabilities } from '@/lib/useSharedQueries'
import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
import {
loadInfoFields,
saveInfoFields,
buildInfoExtColumnsParam,
type ColumnConfig,
} from '@/lib/stock-info-fields'
interface Props {
symbol: string
height?: number
showIntraday?: boolean
className?: string
/** 当用户点击蜡烛选中日期时回调(用于外部自动开启分时图)。 */
onSelectDate?: (date: string) => void
/** 外部传入的日期范围 */
dateRange?: { start: string; end: string }
markers?: ChartMarker[]
ranges?: ChartRange[]
priceLines?: ChartPriceLine[]
showLimitMarkers?: boolean
showMarkerToggle?: boolean
/** 加监控回调 (传入后信息条显示 RadioTower 图标) */
onMonitor?: () => void
/** 加自选 (传入后信息条显示 Star 图标) */
inWatchlist?: boolean
onToggleWatchlist?: () => void
}
export { getDefaultRange }
export function StockPanel({
symbol,
height = 520,
showIntraday = true,
className,
onSelectDate,
dateRange: externalDateRange,
markers,
ranges,
priceLines,
showLimitMarkers = true,
showMarkerToggle = true,
onMonitor,
inWatchlist,
onToggleWatchlist,
}: Props) {
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [dailyResult, setDailyResult] = useState<StockDailyKChartResult | null>(null)
// 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据
const [fields, setFields] = useState<ColumnConfig[]>(loadInfoFields)
const extColumns = useMemo(() => buildInfoExtColumnsParam(fields), [fields])
const handleFieldsChange = useCallback((next: ColumnConfig[]) => {
setFields(next)
saveInfoFields(next)
}, [])
// 财务指标:仅当信息条配置含可见的财务字段且用户具备 FINANCIAL 能力 (Expert) 时才请求
// 无能力时跳过请求, 避免后端抛 CapabilityDenied (403) 导致 free/starter 档弹错误提示
const { data: caps } = useCapabilities()
const hasFinancialCap = !!caps?.capabilities?.['financial']
const hasFinanceField = useMemo(
() => fields.some(f => f.visible && f.source.type === 'builtin'
&& ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'].includes(f.source.key)),
[fields],
)
const financials = useFinancialMetrics(hasFinanceField && hasFinancialCap ? symbol : undefined)
const dateRange = externalDateRange ?? getDefaultRange()
const handleDateClick = useCallback((date: string) => {
setSelectedDate(date)
onSelectDate?.(date)
}, [onSelectDate])
const rows = dailyResult?.rows ?? []
const stockInfo = dailyResult?.stockInfo
const rawRows: KlineRow[] = dailyResult?.rawRows ?? []
// symbol 变化时重置分时相关状态,避免切股后残留旧日期。
// 注意:必须跳过首次挂载——重开弹窗时 kline 命中 react-query 缓存,
// 子组件 onDataChange effect(先于父 effect 执行)会把 dailyResult 置为有效数据,
// 若此处再无条件清空,会把刚加载的数据抹掉,导致信息条整行消失。
const prevSymbol = useRef<string | null>(symbol)
useEffect(() => {
if (prevSymbol.current === symbol) return
prevSymbol.current = symbol
setSelectedDate(null)
setLinkedPrice(null)
setDailyResult(null)
}, [symbol])
// 当分时开启、无选中日期时,自动选中最新日期
useEffect(() => {
if (showIntraday && !selectedDate && rows.length > 0) {
setSelectedDate(rows[rows.length - 1].date)
}
}, [showIntraday, selectedDate, rows])
const selectedIdx = selectedDate ? rows.findIndex(r => r.date === selectedDate) : -1
const prevClose = selectedIdx > 0
? rows[selectedIdx - 1].close
: rows.length >= 2
? rows[rows.length - 2].close
: undefined
if (!symbol) return null
// 财务指标最新一期(metrics 按 period_end 排序,取首项)
const financialMetrics: FinancialMetricRecord | undefined = financials.data?.data?.[0]
return (
<div className={className}>
<StockInfoBar
symbol={symbol}
name={dailyResult?.name}
stockInfo={stockInfo}
rows={rawRows}
fields={fields}
onFieldsChange={handleFieldsChange}
financialMetrics={financialMetrics}
onMonitor={onMonitor}
inWatchlist={inWatchlist}
onToggleWatchlist={onToggleWatchlist}
/>
<div className="flex gap-3 items-start">
<StockDailyKChart
symbol={symbol}
height={height}
className="flex-1 min-w-0"
dateRange={dateRange}
markers={markers}
ranges={ranges}
priceLines={priceLines}
showLimitMarkers={showLimitMarkers}
showMarkerToggle={showMarkerToggle}
linkedPrice={linkedPrice}
onDateClick={handleDateClick}
onDataChange={setDailyResult}
visibleBars={showIntraday ? 40 : 60}
extColumns={extColumns}
/>
{showIntraday && selectedDate && (
<StockIntradayChart
symbol={symbol}
date={selectedDate}
height={height}
prevClose={prevClose}
onPriceHover={setLinkedPrice}
className="flex-1 min-w-0 border-l border-border pl-3"
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,279 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { X, RefreshCw, Clock } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cnSignal } from '@/lib/signals'
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
import { DatePicker } from '@/components/DatePicker'
import { RuleEditor } from '@/components/monitor/RuleEditor'
interface Props {
symbol: string | null
name?: string
onClose: () => void
/** 触发信息 (来自监控触发记录, 有值时在顶栏下方显示) */
triggerInfo?: {
price?: number | null
changePct?: number | null
ts?: number
signals?: string[]
message?: string
} | null
}
// ===== 板块标识(与 Screener 列表一致)=====
// 预设快捷范围(只保留半年和1年)
const PRESETS: { label: string; months: number }[] = [
{ label: '半年', months: 6 },
{ label: '1年', months: 12 },
]
function boardTag(symbol: string): { label: string; color: string } | null {
if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' }
if (/^688/.test(symbol)) return { label: '科', color: 'text-purple-400 bg-purple-400/12 border-purple-400/25' }
if (/^[48]/.test(symbol)) return { label: '北', color: 'text-cyan-400 bg-cyan-400/12 border-cyan-400/25' }
return null
}
export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) {
const [showIntraday, setShowIntraday] = useState(false)
const [dateRange, setDateRange] = useState(getDefaultRange)
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
const qc = useQueryClient()
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: api.watchlistList,
enabled: !!symbol,
})
const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol)
const toggleWatchlist = useMutation({
mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
},
})
// ESC 关闭
useEffect(() => {
if (!symbol) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [symbol, onClose])
const handleRefresh = () => {
if (!symbol) return
qc.invalidateQueries({ queryKey: ['kline', symbol!] })
if (showIntraday) {
qc.invalidateQueries({ queryKey: ['kline-minute', symbol!] })
}
}
return (
<AnimatePresence>
{symbol && (
<div className="fixed inset-0 z-50 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/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* 弹窗主体 */}
<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-[92vw] max-w-[1100px] max-h-[95vh] rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col"
>
{/* 顶栏 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
{(() => {
const board = symbol ? boardTag(symbol) : null
return board ? (
<span className={`inline-flex items-center justify-center w-[18px] h-[18px] rounded text-[9px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
) : null
})()}
<span className="font-mono text-sm font-medium text-foreground">{symbol}</span>
{name && <span className="text-xs text-muted">{name}</span>}
</div>
<div className="flex items-center gap-1.5">
{/* 日期范围快捷 */}
{PRESETS.map(p => {
const now = new Date()
const s = new Date(now)
s.setMonth(s.getMonth() - p.months)
const expected = s.toISOString().slice(0, 10)
const isActive = dateRange.start === expected
return (
<button
key={p.label}
onClick={() => {
const end = new Date().toISOString().slice(0, 10)
const ns = new Date()
ns.setMonth(ns.getMonth() - p.months)
setDateRange({ start: ns.toISOString().slice(0, 10), end })
}}
className={`h-6 px-1.5 rounded text-[11px] transition-colors cursor-pointer
${isActive
? 'bg-accent/20 text-accent font-medium border border-accent/30'
: 'text-muted hover:text-foreground hover:bg-elevated border border-transparent'
}`}
>
{p.label}
</button>
)
})}
<DatePicker
value={dateRange.start}
onChange={(v) => setDateRange(prev => ({ ...prev, start: v }))}
max={dateRange.end}
/>
<span className="text-muted/40 text-[10px]">~</span>
<DatePicker
value={dateRange.end}
onChange={(v) => setDateRange(prev => ({ ...prev, end: v }))}
min={dateRange.start}
/>
<span className="text-muted/20 mx-0.5">|</span>
{/* 分时开关 */}
<button
onClick={() => setShowIntraday((v) => !v)}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors ${
showIntraday
? 'bg-accent/15 text-accent border border-accent/30'
: 'bg-elevated text-secondary border border-border hover:border-accent/30'
}`}
>
<Clock className="h-3 w-3" />
</button>
<span className="text-muted/20 mx-0.5">|</span>
{/* 刷新 */}
<button
onClick={handleRefresh}
className="p-1 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors"
title="刷新"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
{/* 关闭 */}
<button
onClick={onClose}
className="p-1 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 触发信息条 (来自监控触发记录) */}
{triggerInfo && (
<div className="flex items-center gap-4 border-b border-amber-400/20 bg-amber-400/[0.06] px-5 py-2 shrink-0">
{/* 左: 触发标记 + 时间 */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-semibold text-amber-400"> </span>
{triggerInfo.ts && (
<span className="text-[11px] text-secondary font-mono">
{new Date(triggerInfo.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
{/* 中: 价格 + 涨跌幅 */}
<div className="flex items-center gap-2 shrink-0">
{triggerInfo.price != null && (
<span className="text-[11px] font-mono text-foreground/80">{triggerInfo.price.toFixed(2)}</span>
)}
{triggerInfo.changePct != null && (
<span className={`text-[11px] font-mono font-medium ${triggerInfo.changePct >= 0 ? 'text-danger' : 'text-bear'}`}>
{triggerInfo.changePct >= 0 ? '+' : ''}{(triggerInfo.changePct * 100).toFixed(2)}%
</span>
)}
</div>
{/* 右: 消息 + 信号标签 */}
<div className="flex items-center gap-2 flex-wrap min-w-0">
{triggerInfo.message && (
<span className="text-[11px] text-foreground/70 truncate">{triggerInfo.message}</span>
)}
{triggerInfo.signals && triggerInfo.signals.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{triggerInfo.signals.map((s, j) => (
<span key={j} className="rounded bg-accent/10 px-1.5 py-0.5 text-[9px] text-accent/80">{cnSignal(s)}</span>
))}
</div>
)}
</div>
</div>
)}
{/* K 线内容 */}
<div className="flex-1 overflow-auto p-4">
<StockPanel
symbol={symbol}
height={420}
showIntraday={showIntraday}
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
dateRange={dateRange}
onMonitor={() => setShowMonitorEditor(true)}
inWatchlist={inWatchlist}
onToggleWatchlist={() => toggleWatchlist.mutate()}
/>
</div>
{/* 加监控编辑器弹层 */}
<AnimatePresence>
{showMonitorEditor && symbol && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-20 flex items-start justify-center overflow-auto bg-black/40 p-4"
onClick={() => setShowMonitorEditor(false)}
>
<div className="mt-8 w-full max-w-2xl" onClick={e => e.stopPropagation()}>
<RuleEditor
rule={null}
simple
preset={{
scope: 'symbols',
symbols: [symbol],
type: 'signal',
logic: 'or',
}}
onClose={() => setShowMonitorEditor(false)}
onSaved={() => setShowMonitorEditor(false)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
+78
View File
@@ -0,0 +1,78 @@
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
}
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useState } from 'react'
// ===== 全局 toast 状态 =====
type ToastItem = { id: number; msg: string; kind: 'error' | 'success' }
let _id = 0
const _listeners: Set<(items: ToastItem[]) => void> = new Set()
let _queue: ToastItem[] = []
function _emit() { _listeners.forEach(fn => fn([..._queue])) }
function toast(msg: string, kind: 'error' | 'success' = 'error') {
const item = { id: ++_id, msg, kind }
_queue = [..._queue, item]
_emit()
setTimeout(() => { _queue = _queue.filter(t => t.id !== item.id); _emit() }, 4000)
}
export { toast }
// ===== Toast 容器 — 挂在 Layout 最顶层 =====
export function ToastContainer() {
const [items, setItems] = useState<ToastItem[]>([])
const sub = useCallback(() => {
_listeners.add(setItems)
return () => { _listeners.delete(setItems) }
}, [])
useEffect(sub, [sub])
if (!items.length) return null
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none">
{items.map(t => (
<div
key={t.id}
className={`pointer-events-auto px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 fade-in duration-200 ${
t.kind === 'error'
? 'bg-red-500/90 text-white'
: 'bg-emerald-500/90 text-white'
}`}
>
{t.msg}
</div>
))}
</div>
)
}
+451
View File
@@ -0,0 +1,451 @@
/**
* 概念/行业分析 — 共享 UI 组件
*
* 包含:
* - AnalysisConfigDialog: 字段配置弹窗
* - DimensionHeatmap: 维度热力图块
* - OverviewStatCards: 总览统计卡片
* - DimensionGroupSidebar: 维度分组侧边栏
*/
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import {
BarChart3,
ChevronDown,
Database,
Search,
Settings2,
Tags,
TrendingUp,
X,
} from 'lucide-react'
import { api, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
import type { DimensionGroup, QuoteMap } from '@/lib/analysis-adapter'
import { computeQuoteMetrics } from '@/lib/analysis-adapter'
import { fmtPct, priceColorClass } from '@/lib/format'
// ===== 配置类型 =====
export interface AnalysisFieldConfig {
/** 选中的扩展数据源 ID */
configId?: string
/** 手动指定的维度字段(覆盖自动探测) */
dimensionField?: string
/** 层级字段统计级别(如行业 1/2/3 级) */
hierarchyLevel?: 1 | 2 | 3
}
export interface SchemaOption {
id: string
label: string
columns: { name: string; type: string; label: string }[]
}
// ===== 配置弹窗 =====
export function AnalysisConfigDialog({
currentConfig,
onSave,
onClose,
showHierarchyLevel = false,
}: {
currentConfig: AnalysisFieldConfig
onSave: (config: AnalysisFieldConfig) => void
onClose: () => void
showHierarchyLevel?: boolean
}) {
const [draft, setDraft] = useState<AnalysisFieldConfig>(currentConfig)
const { data: extList } = useQuery({
queryKey: QK.extData,
queryFn: api.extDataList,
})
const { data: schemaData } = useQuery({
queryKey: QK.extDataSchemaAll,
queryFn: api.extDataSchemaAll,
})
const configs = extList?.items ?? []
const fieldsForConfig = useMemo((): ExtDataField[] => {
if (!draft.configId || !schemaData?.items) return []
const schema = schemaData.items.find(s => s.id === draft.configId)
return schema?.columns.map(c => ({ name: c.name, dtype: c.type, label: c.label })) ?? []
}, [draft.configId, schemaData])
const nonMetaFields = fieldsForConfig.filter(
f => !['symbol', 'code', 'date', 'name'].includes(f.name),
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-surface border border-border rounded-lg shadow-xl w-[420px]"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 pt-3 pb-2">
<span className="text-sm font-medium"></span>
<button onClick={onClose} className="p-0.5 text-muted hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 pb-4 space-y-3">
{/* 数据源选择 */}
<div className="space-y-1.5">
<span className="text-xs text-secondary"></span>
<select
value={draft.configId ?? ''}
onChange={e => setDraft(d => ({ ...d, configId: e.target.value || undefined, dimensionField: undefined }))}
className="w-full h-8 bg-elevated border border-border rounded text-xs text-foreground px-2 focus:outline-none focus:border-accent/50"
>
<option value=""></option>
{configs.map(c => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
</div>
{/* 维度字段选择 */}
{nonMetaFields.length > 0 && (
<div className="space-y-1.5">
<span className="text-xs text-secondary"></span>
<select
value={draft.dimensionField ?? ''}
onChange={e => setDraft(d => ({ ...d, dimensionField: e.target.value || undefined }))}
className="w-full h-8 bg-elevated border border-border rounded text-xs text-foreground px-2 focus:outline-none focus:border-accent/50"
>
<option value=""></option>
{nonMetaFields.map(f => (
<option key={f.name} value={f.name}>{f.label || f.name}</option>
))}
</select>
</div>
)}
{showHierarchyLevel && (
<div className="space-y-1.5">
<span className="text-xs text-secondary"></span>
<select
value={draft.hierarchyLevel ?? 2}
onChange={e => setDraft(d => ({ ...d, hierarchyLevel: Number(e.target.value) as 1 | 2 | 3 }))}
className="w-full h-8 bg-elevated border border-border rounded text-xs text-foreground px-2 focus:outline-none focus:border-accent/50"
>
<option value={1}></option>
<option value={2}></option>
<option value={3}></option>
</select>
</div>
)}
<div className="text-[10px] text-muted leading-relaxed">
"个股→概念/行业""概念/行业→成分股列表"
{showHierarchyLevel ? ' 行业字段按 “-” 拆分为 1/2/3 级。' : ''}
</div>
</div>
<div className="flex justify-end gap-2 px-4 py-3 border-t border-border">
<button onClick={onClose} className="px-3 py-1.5 text-xs text-secondary hover:text-foreground"></button>
<button
onClick={() => { onSave(draft); onClose() }}
className="px-3 py-1.5 text-xs bg-accent/15 text-accent rounded hover:bg-accent/25"
>
</button>
</div>
</motion.div>
</div>
)
}
// ===== 总览统计卡片 =====
export function OverviewStatCards({
groups,
totalStocks,
configLabel,
dateStr,
accentColor,
}: {
groups: DimensionGroup[]
totalStocks: number
configLabel: string
dateStr?: string | null
accentColor: string
}) {
const cards = [
{ label: '数据源', value: configLabel, hint: dateStr ?? '快照', icon: Database },
{ label: '维度数量', value: groups.length, hint: '按维度聚合分组', icon: Tags },
{ label: '覆盖标的', value: totalStocks, hint: '去重股票数', icon: BarChart3 },
{
label: '最大分组',
value: groups[0]?.key ?? '—',
hint: groups[0] ? `${groups[0].count}` : '',
icon: TrendingUp,
},
]
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{cards.map(card => (
<div key={card.label} className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted">{card.label}</span>
<card.icon className="h-4 w-4" style={{ color: accentColor }} />
</div>
<div className="mt-2 text-xl font-semibold tracking-tight text-foreground truncate">
{card.value}
</div>
<div className="mt-1 text-[11px] text-muted truncate">{card.hint}</div>
</div>
))}
</div>
)
}
// ===== 维度热力图 =====
export function DimensionHeatmap({
groups,
quoteMap,
selectedKey,
onSelect,
colorScheme,
}: {
groups: DimensionGroup[]
quoteMap: Map<string, QuoteMap>
selectedKey: string | null
onSelect: (key: string | null) => void
colorScheme: 'blue' | 'amber'
}) {
const [showAll, setShowAll] = useState(false)
// 收起时最大高度(约 3 行标签高度)
const collapsedMaxH = '8rem'
const enriched = useMemo(() => {
return groups.map(g => {
const qm = computeQuoteMetrics(g.stocks, quoteMap)
return { ...g, quoteMetrics: qm }
})
}, [groups, quoteMap])
// 根据涨跌比渲染颜色强度
const colors = colorScheme === 'blue'
? { up: [59, 130, 246], down: [96, 165, 250], bg: [30, 64, 175] }
: { up: [245, 158, 11], down: [251, 191, 36], bg: [180, 83, 9] }
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted"></span>
<button
onClick={() => setShowAll(v => !v)}
className="text-[10px] text-muted hover:text-foreground flex items-center gap-0.5"
>
{showAll ? '收起' : `展开全部 (${enriched.length})`}
<ChevronDown className={`h-3 w-3 transition-transform ${showAll ? 'rotate-180' : ''}`} />
</button>
</div>
<div
className="flex flex-wrap gap-1.5 overflow-hidden transition-[max-height] duration-200"
style={{ maxHeight: showAll ? 'none' : collapsedMaxH }}
>
{enriched.map(g => {
const qm = g.quoteMetrics
const total = qm.upCount + qm.downCount + qm.flatCount
const upRatio = total > 0 ? qm.upCount / total : 0.5
const size = Math.max(0.6, Math.min(1.4, g.count / (enriched[0]?.count || 1)))
const active = selectedKey === g.key
const [r, gr, b] = colors.up
return (
<button
key={g.key}
onClick={() => onSelect(active ? null : g.key)}
className="px-2.5 py-1.5 rounded-sm text-[11px] whitespace-nowrap cursor-pointer hover:brightness-125 transition-all"
style={{
fontSize: `${10 + size * 2}px`,
color: active ? '#fff' : `rgba(${r},${gr},${b},${0.6 + upRatio * 0.4})`,
backgroundColor: active
? `rgba(${r},${gr},${b},0.7)`
: `rgba(${r},${gr},${b},${0.08 + upRatio * 0.15})`,
outline: active ? `1px solid rgba(${r},${gr},${b},0.8)` : 'none',
outlineOffset: 1,
}}
title={`${g.key}: ${g.count}只, 涨${qm.upCount}/跌${qm.downCount}, 均幅${qm.avgPct != null ? fmtPct(qm.avgPct) : '—'}`}
>
{g.key}
<span className="ml-1 opacity-60">{g.count}</span>
{qm.avgPct != null && (
<span className={`ml-1 ${priceColorClass(qm.avgPct)}`} style={{ fontSize: '9px' }}>
{fmtPct(qm.avgPct)}
</span>
)}
</button>
)
})}
</div>
</div>
)
}
// ===== 维度分组侧边栏 =====
export function DimensionGroupSidebar({
groups,
quoteMap,
selectedKey,
onSelect,
searchValue,
onSearchChange,
kindLabel,
colorScheme,
}: {
groups: DimensionGroup[]
quoteMap: Map<string, QuoteMap>
selectedKey: string | null
onSelect: (key: string) => void
searchValue: string
onSearchChange: (v: string) => void
kindLabel: string
colorScheme: 'blue' | 'amber'
}) {
const q = searchValue.trim().toLowerCase()
const filtered = q
? groups.filter(g => g.key.toLowerCase().includes(q))
: groups
const accentColor = colorScheme === 'blue' ? 'rgba(59,130,246,0.7)' : 'rgba(245,158,11,0.7)'
const accentBg = colorScheme === 'blue' ? 'rgba(59,130,246,0.1)' : 'rgba(245,158,11,0.1)'
const accentBorder = colorScheme === 'blue' ? 'rgba(59,130,246,0.25)' : 'rgba(245,158,11,0.25)'
return (
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground">{kindLabel}</h3>
<p className="mt-0.5 text-[11px] text-muted"></p>
</div>
<div className="p-3 border-b border-border/60">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
value={searchValue}
onChange={e => onSearchChange(e.target.value)}
placeholder={`搜索${kindLabel}`}
className="h-8 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div className="max-h-[560px] overflow-auto p-2 space-y-1">
{filtered.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-muted"></div>
) : filtered.slice(0, 200).map((group, i) => {
const active = selectedKey === group.key
const qm = computeQuoteMetrics(group.stocks, quoteMap)
return (
<button
key={group.key}
onClick={() => onSelect(group.key)}
className={cn(
'w-full rounded-lg px-3 py-2 text-left transition-colors',
active ? 'border' : 'border border-transparent hover:bg-elevated/60',
)}
style={active ? { backgroundColor: accentBg, borderColor: accentBorder } : undefined}
>
<div className="flex items-center gap-2">
<span className="w-5 text-[10px] font-mono text-muted">#{i + 1}</span>
<span className="flex-1 truncate text-xs font-medium text-foreground">{group.key}</span>
<span className="font-mono text-xs text-secondary">{group.count}</span>
</div>
{/* 覆盖量进度条 */}
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.max(6, (group.count / (filtered[0]?.count || 1)) * 100)}%`,
backgroundColor: accentColor,
}}
/>
</div>
{/* 行情摘要 */}
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] text-muted">
{qm.avgPct != null && (
<span className={priceColorClass(qm.avgPct)}>
{fmtPct(qm.avgPct)}
</span>
)}
{qm.upCount + qm.downCount > 0 && (
<span>
<span className="text-bull">{qm.upCount}</span>
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{qm.downCount}</span>
</span>
)}
</div>
</button>
)
})}
</div>
</section>
)
}
// ===== 行情摘要条 =====
export function QuoteSummaryBar({
groups,
quoteMap,
}: {
groups: DimensionGroup[]
quoteMap: Map<string, QuoteMap>
}) {
const stats = useMemo(() => {
let upTotal = 0, downTotal = 0, flatTotal = 0
for (const g of groups) {
const qm = computeQuoteMetrics(g.stocks, quoteMap)
upTotal += qm.upCount
downTotal += qm.downCount
flatTotal += qm.flatCount
}
return { upTotal, downTotal, flatTotal }
}, [groups, quoteMap])
const total = stats.upTotal + stats.downTotal + stats.flatTotal
if (total === 0) return null
const upPct = (stats.upTotal / total) * 100
const downPct = (stats.downTotal / total) * 100
return (
<div className="flex items-center gap-3 text-[11px]">
<div className="flex-1 h-2 rounded-full overflow-hidden bg-elevated flex">
<div className="h-full bg-bull/70 rounded-l-full" style={{ width: `${upPct}%` }} />
<div className="flex-1" />
<div className="h-full bg-bear/70 rounded-r-full" style={{ width: `${downPct}%` }} />
</div>
<span className="text-bull font-mono">{stats.upTotal}</span>
<span className="text-muted">/</span>
<span className="text-bear font-mono">{stats.downTotal}</span>
</div>
)
}
// ===== 配置按钮 =====
export function ConfigButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="p-1.5 hover:bg-surface text-muted hover:text-accent transition-colors"
title="配置数据源"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
)
}
@@ -0,0 +1,135 @@
import { useRef, useMemo, useEffect } from 'react'
import { motion } from 'framer-motion'
import { Loader2, CheckCircle2, XCircle } from 'lucide-react'
import { formatDuration, formatLogTime } from '@/lib/format'
import { Pill } from './StatCard'
import type { PipelineJob } from '@/lib/api'
export const STAGE_LABELS: Record<string, string> = {
init: '初始化',
resolve_universe: '解析标的池',
sync_instruments: '同步标的维表',
sync_daily: '同步日 K',
sync_adj: '同步除权因子',
compute_enriched: '计算技术指标',
sync_minute: '同步分钟 K',
extend_history: '扩展日K历史',
extend_minute: '扩展分钟K历史',
rebuild_enriched: '全量计算',
refresh_views: '刷新视图',
done: '完成',
}
function LogViewer({ log }: { log: PipelineJob['log'] }) {
const containerRef = useRef<HTMLDivElement>(null)
const displayLog = useMemo(() => {
if (!log.length) return []
return log.filter((line, i) => {
const next = log[i + 1]
if (
next &&
line.stage === next.stage &&
/\d+\/\d+/.test(line.msg) &&
/\d+\/\d+/.test(next.msg)
) {
return false
}
return true
})
}, [log])
useEffect(() => {
const el = containerRef.current
if (el) el.scrollTop = el.scrollHeight
}, [displayLog])
return (
<div ref={containerRef} className="rounded-btn bg-base/60 border border-border max-h-48 overflow-y-auto px-3 py-2 font-mono text-[11px] space-y-0.5">
{displayLog.map((line, i) => (
<div key={`${line.ts}-${i}`} className="flex gap-2 text-secondary">
<span className="text-muted shrink-0">{formatLogTime(line.ts)}</span>
<span className="text-accent/70 shrink-0">[{STAGE_LABELS[line.stage] ?? line.stage}]</span>
<span>{line.msg}</span>
</div>
))}
{displayLog.length === 0 && <div className="text-muted"></div>}
</div>
)
}
export function ActiveJobCard({ job }: { job: PipelineJob }) {
const statusMap = {
running: { icon: Loader2, color: 'text-accent', label: '运行中', spinning: true, border: 'border-accent/40', bg: 'bg-accent/5' },
pending: { icon: Loader2, color: 'text-muted', label: '排队中', spinning: true, border: 'border-border', bg: 'bg-surface' },
succeeded: { icon: CheckCircle2, color: 'text-bear', label: '完成', spinning: false, border: 'border-bear/30', bg: 'bg-bear/5' },
failed: { icon: XCircle, color: 'text-danger', label: '失败', spinning: false, border: 'border-danger/40', bg: 'bg-danger/5' },
} as const
const meta = statusMap[job.status]
const Icon = meta.icon
const isDone = job.status === 'succeeded' || job.status === 'failed'
const stageLabel = isDone ? meta.label : (STAGE_LABELS[job.stage] ?? job.stage)
return (
<div className={`rounded-card border ${meta.border} ${meta.bg} p-5`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<Icon className={`h-5 w-5 ${meta.color} ${meta.spinning ? 'animate-spin' : ''}`} />
<div>
<div className="text-sm font-medium text-foreground">
{meta.label}{!isDone && ` · ${stageLabel}`}
</div>
<div className="text-xs text-secondary font-mono mt-0.5">
{job.id} · {job.duration_s != null ? formatDuration(job.duration_s) : '进行中'}
</div>
</div>
</div>
{!isDone && (
<div className="font-mono text-2xl font-bold tracking-tight">
{job.progress}<span className="text-base text-muted">%</span>
</div>
)}
</div>
{!isDone && (
<div className="mb-2">
<div className="h-1.5 rounded-full bg-elevated overflow-hidden">
<motion.div
className="h-full bg-accent"
animate={{ width: `${job.progress}%` }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
/>
</div>
{job.stage_pct > 0 && (
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] text-muted"></span>
<span className="text-[10px] font-mono text-secondary">{job.stage_pct}%</span>
</div>
)}
</div>
)}
<LogViewer log={job.log} />
{job.status === 'succeeded' && job.result && (() => {
const skipped = new Set(job.result.skipped_stages ?? [])
const cell = (stage: string | null, v: string) =>
stage && skipped.has(stage) ? '跳过' : v
return (
<div className="mt-3 grid grid-cols-2 md:grid-cols-5 gap-3 text-xs">
<Pill label="标的池" value={job.result.universe_size ?? '—'} />
<Pill label="日 K" value={cell(null, `${job.result.daily_days ?? 0}`)} />
<Pill label="除权因子" value={cell('sync_adj', `${job.result.adj_factor_symbols ?? 0}`)} />
<Pill label="enriched" value={cell(null, `${job.result.enriched_days ?? 0}`)} />
<Pill label="分钟K" value={cell('sync_minute', `${job.result.minute_rows ?? 0}`)} />
</div>
)
})()}
{job.status === 'failed' && job.error && (
<div className="mt-3 rounded-btn border border-danger/40 bg-danger/5 px-3 py-2 text-xs text-danger">
{job.error}
</div>
)}
</div>
)
}
@@ -0,0 +1,143 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences, useCapabilities } from '@/lib/useSharedQueries'
import { isExpertOrAbove } from '@/lib/capability-labels'
/**
* 五档盘口 sealed(真假涨停) 配置内容(纯内容, 无外框, 由父级 Card 包裹)。
*
* - 轮询间隔: Pro 10~120s / Expert 3~300s
* - 盘后定版时间: 15:01~18:00, 默认 15:02
* - disabled 时(监控关闭)输入框禁用
*/
// 注: 文件名保留 DepthConfigCard.tsx, 导出 DepthConfigContent(纯内容无外框)
export function DepthConfigContent({ disabled }: { disabled?: boolean }) {
const qc = useQueryClient()
const prefs = usePreferences()
const caps = useCapabilities()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const tierLabel = caps.data?.label ?? ''
const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 300 } : { lo: 10, hi: 120 }
const interval = prefs.data?.depth_polling_interval ?? 20
const finalizeTime = prefs.data?.depth_finalize_time ?? { hour: 15, minute: 2 }
const [intervalInput, setIntervalInput] = useState(String(Math.round(interval)))
const [finalizeHour, setFinalizeHour] = useState(String(finalizeTime.hour))
const [finalizeMinute, setFinalizeMinute] = useState(String(finalizeTime.minute))
useEffect(() => { setIntervalInput(String(Math.round(interval))) }, [interval])
useEffect(() => {
setFinalizeHour(String(finalizeTime.hour))
setFinalizeMinute(String(finalizeTime.minute))
}, [finalizeTime.hour, finalizeTime.minute])
const saveInterval = useMutation({
mutationFn: (v: number) => api.updateDepthPollingInterval(v),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const saveFinalize = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updateDepthFinalizeTime(hour, minute),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
// 无能力: 显示升级提示
if (!hasDepth) {
return (
<p className="text-xs text-muted leading-relaxed">
, <span className="text-accent">Pro </span>
()()
</p>
)
}
const inputCls = `w-16 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`
return (
<div className="space-y-3">
{/* 盘中轮询间隔 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> {range.lo}~{range.hi} · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={range.lo}
max={range.hi}
value={intervalInput}
disabled={disabled}
onChange={e => setIntervalInput(e.target.value)}
onBlur={() => {
if (disabled) return
let v = Number(intervalInput)
if (!Number.isFinite(v)) v = range.lo
v = Math.max(range.lo, Math.min(range.hi, v))
saveInterval.mutate(v)
}}
className={inputCls}
/>
<span className="text-xs text-muted"></span>
</div>
</div>
{/* 盘后定版时间 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> 15:01~18:00 · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={15}
max={18}
value={finalizeHour}
disabled={disabled}
onChange={e => setFinalizeHour(e.target.value)}
onBlur={() => {
if (disabled) return
let h = Number(finalizeHour)
if (!Number.isFinite(h)) h = 15
h = Math.max(15, Math.min(18, h))
let m = Number(finalizeMinute)
if (!Number.isFinite(m)) m = 2
m = Math.max(0, Math.min(59, m))
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
saveFinalize.mutate({ hour: h, minute: m })
}}
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
/>
<span className="text-xs text-muted">:</span>
<input
type="number"
min={0}
max={59}
value={finalizeMinute}
disabled={disabled}
onChange={e => setFinalizeMinute(e.target.value)}
onBlur={() => {
if (disabled) return
let h = Number(finalizeHour)
if (!Number.isFinite(h)) h = 15
h = Math.max(15, Math.min(18, h))
let m = Number(finalizeMinute)
if (!Number.isFinite(m)) m = 2
m = Math.max(0, Math.min(59, m))
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
saveFinalize.mutate({ hour: h, minute: m })
}}
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences } from '@/lib/useSharedQueries'
export function EnrichedRebuildPanel({ isRunning, onStart }: { isRunning: boolean; onStart: () => void }) {
const qc = useQueryClient()
const prefs = usePreferences()
const batchSize = prefs.data?.enriched_batch_size ?? 1000
const [editing, setEditing] = useState(false)
const [draftSize, setDraftSize] = useState(String(batchSize))
const [hint, setHint] = useState<string | null>(null)
function clampAndSave(raw: number) {
if (isNaN(raw) || 1 > raw) { setHint('已自动设为最小值 1'); saveBatch.mutate(1); return }
if (raw > 10000) { setHint('已自动设为上限 10000'); saveBatch.mutate(10000); return }
setHint(null); saveBatch.mutate(raw)
}
const saveBatch = useMutation({
mutationFn: (size: number) => api.updateEnrichedBatchSize(size),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
setEditing(false)
},
})
const rebuild = useMutation({
mutationFn: api.rebuildEnriched,
onSuccess: () => {
onStart()
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
},
})
return (
<div className="px-4 pb-4 pt-3 border-t border-accent/20 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium text-foreground"></div>
<div className="text-[10px] text-muted"></div>
</div>
{editing ? (
<div className="flex items-center gap-1.5">
<input
type="number"
value={draftSize}
onChange={e => setDraftSize(e.target.value)}
className="w-20 px-2 py-1 text-xs font-mono rounded-btn border border-border bg-surface text-foreground text-right tabular-nums focus:outline-none focus:border-accent"
min={1}
max={10000}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') clampAndSave(parseInt(draftSize))
if (e.key === 'Escape') { setEditing(false); setHint(null) }
}}
/>
<button
onClick={() => clampAndSave(parseInt(draftSize))}
disabled={saveBatch.isPending}
className="px-2 py-1 text-[10px] rounded-btn bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50 transition-colors"
>
{saveBatch.isPending ? '…' : '保存'}
</button>
<button
onClick={() => setEditing(false)}
className="px-2 py-1 text-[10px] rounded-btn bg-elevated text-muted hover:text-foreground transition-colors"
>
</button>
</div>
) : (
<button
onClick={() => { setDraftSize(String(batchSize)); setEditing(true) }}
className="px-2.5 py-1 rounded-btn border border-border bg-surface text-xs font-mono text-foreground hover:border-accent/50 transition-colors tabular-nums"
>
{batchSize} /
</button>
)}
</div>
<div className="flex items-start gap-1.5 px-3 py-1.5 rounded-btn bg-warning/10 border border-warning/20">
<span className="text-[10px] text-warning leading-relaxed">
= × K历史天数K历史越长
</span>
</div>
{hint && (
<div className="px-3 py-1 rounded-btn bg-accent/10 border border-accent/20 text-[10px] text-accent">
{hint}
</div>
)}
</div>
<div>
<div className="text-[10px] text-muted mb-2"> kline_daily + adj_factor + + </div>
<button
onClick={() => rebuild.mutate()}
disabled={isRunning || rebuild.isPending}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
>
{rebuild.isPending ? (
<><Loader2 className="h-3 w-3 animate-spin" /></>
) : (
<></>
)}
</button>
</div>
</div>
)
}
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
export function ExtendHistoryPanel({ caps, isRunning, earliestDate, onStart }: {
caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined
isRunning: boolean
earliestDate: string | null
onStart: () => void
}) {
const qc = useQueryClient()
const [value, setValue] = useState(6)
const [unit, setUnit] = useState<'month' | 'year'>('month')
const hasBatchCap = !!caps?.capabilities?.['kline.daily.batch']
const extend = useMutation({
mutationFn: () => api.extendHistory(value, unit),
onSuccess: () => {
onStart()
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
},
})
const offsetDays = unit === 'month' ? value * 30 : value * 365
const estimate = earliestDate
? (() => {
const d = new Date(earliestDate)
d.setDate(d.getDate() - offsetDays)
return d.toISOString().slice(0, 10)
})()
: null
return (
<div className="px-4 pb-4 pt-3 border-t border-accent/20 space-y-3">
<div className="text-[10px] text-secondary"></div>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => setValue(Math.max(1, value - 1))}
disabled={!hasBatchCap || isRunning}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
></button>
<div className="h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums text-foreground bg-base">
{value}
</div>
<button
onClick={() => setValue(Math.min(unit === 'year' ? 10 : 36, value + 1))}
disabled={!hasBatchCap || isRunning}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
</div>
<div className="flex rounded-btn border border-border overflow-hidden">
{(['month', 'year'] as const).map(u => (
<button
key={u}
onClick={() => { setUnit(u); if (u === 'year' && value > 10) setValue(1); if (u === 'month' && value > 36) setValue(6) }}
className={`px-2 py-0.5 text-[10px] font-medium transition-colors ${
unit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
}`}
>{u === 'month' ? '月' : '年'}</button>
))}
</div>
</div>
{estimate && (
<div className="text-[10px] text-muted">
<span className="font-mono text-secondary">{estimate}</span>
{earliestDate && <span> (: <span className="font-mono text-secondary">{earliestDate}</span>)</span>}
</div>
)}
<button
onClick={() => extend.mutate()}
disabled={!hasBatchCap || isRunning || extend.isPending || !earliestDate}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
>
{extend.isPending ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
</>
) : (
<></>
)}
</button>
{!hasBatchCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
Pro+
</span>
)}
</div>
)
}
@@ -0,0 +1,219 @@
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { isExpertOrAbove } from '@/lib/capability-labels'
export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; isRunning: boolean; onStart: () => void }) {
const qc = useQueryClient()
const prefs = useQuery({
queryKey: QK.preferences,
queryFn: api.preferences,
})
const update = useMutation({
mutationFn: ({ enabled, days }: { enabled: boolean; days: number }) =>
api.updateMinuteSync(enabled, days),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const hasMinuteCap = !!caps?.capabilities?.['kline.minute.batch']
const enabled = prefs.data?.minute_sync_enabled ?? false
const days = prefs.data?.minute_sync_days ?? 5
const [localDays, setLocalDays] = useState(days)
useEffect(() => { setLocalDays(days) }, [days])
const handleToggle = () => {
if (!hasMinuteCap) return
update.mutate({ enabled: !enabled, days: localDays })
}
return (
<div className="px-4 pb-4 pt-3 border-t border-accent/20 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<button
onClick={handleToggle}
disabled={!hasMinuteCap}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 shrink-0 ${
enabled ? 'bg-accent shadow-[0_0_6px_rgba(61,214,140,0.3)]' : 'bg-elevated'
} ${!hasMinuteCap ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
enabled ? 'translate-x-[18px]' : 'translate-x-0.5'
}`}
/>
</button>
<span className="text-xs text-foreground font-medium">
{enabled ? '自动同步' : '已关闭'}
</span>
</div>
{!hasMinuteCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
Pro+
</span>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-secondary"></span>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => { const v = Math.max(1, localDays - 1); setLocalDays(v); update.mutate({ enabled, days: v }) }}
disabled={!hasMinuteCap || !enabled || localDays <= 1}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>
</button>
<div
className={`h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums ${
enabled ? 'text-foreground bg-base' : 'text-muted bg-elevated/50'
}`}
>
{localDays}
</div>
<button
onClick={() => { const v = Math.min(15, localDays + 1); setLocalDays(v); update.mutate({ enabled, days: v }) }}
disabled={!hasMinuteCap || !enabled || localDays >= 15}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>
+
</button>
</div>
<span className="text-[10px] text-muted"></span>
</div>
</div>
<div className="pt-2 border-t border-border space-y-2.5">
<div className="text-[10px] text-secondary"></div>
<MinuteExtendControls hasMinuteCap={hasMinuteCap} tierLabel={caps?.label ?? ''} isRunning={isRunning} onStart={onStart} />
</div>
<div className="text-[10px] text-muted">
A股标的 · ()
</div>
</div>
)
}
function MinuteExtendControls({ hasMinuteCap, tierLabel, isRunning, onStart }: { hasMinuteCap: boolean; tierLabel: string; isRunning: boolean; onStart: () => void }) {
const qc = useQueryClient()
// 月单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用"天"(1~15 天)
const canUseMonth = isExpertOrAbove(tierLabel)
const [unit, setUnit] = useState<'day' | 'month'>('day')
const [value, setValue] = useState(5)
const [confirmOpen, setConfirmOpen] = useState(false)
const dataStatus = useQuery({
queryKey: QK.dataStatus,
queryFn: api.dataStatus,
})
// 判断本地是否已有分钟K数据:后端 _safe_aggregate_minute 为避免全表扫描,
// rows 恒为 0,改用 trading_days(分区目录数,真实统计)判断。
const hasMinuteData = !!(dataStatus.data?.minute?.trading_days)
const extend = useMutation({
mutationFn: () => api.extendMinuteHistory(value, unit),
onSuccess: () => {
onStart()
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
qc.invalidateQueries({ queryKey: QK.dataStatus })
},
})
// 各单位上限:day 15 天,month 6 月(180 天)
const maxValue = unit === 'month' ? 6 : 15
const handleFetch = () => {
if (!hasMinuteData) {
setConfirmOpen(true)
} else {
extend.mutate()
}
}
// 切换单位时把 value clamp 到新单位的上限
const switchUnit = (u: 'day' | 'month') => {
if (u === unit) return
setUnit(u)
const max = u === 'month' ? 6 : 15
setValue(v => Math.min(v, max))
}
return (
<>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => setValue(Math.max(1, value - 1))}
disabled={!hasMinuteCap || isRunning || extend.isPending}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
></button>
<div className="h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums text-foreground bg-base">
{value}
</div>
<button
onClick={() => setValue(Math.min(maxValue, value + 1))}
disabled={!hasMinuteCap || isRunning || extend.isPending || value >= maxValue}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
</div>
{canUseMonth ? (
<div className="flex rounded-btn border border-border overflow-hidden">
{(['day', 'month'] as const).map(u => (
<button
key={u}
onClick={() => switchUnit(u)}
className={`px-2 py-0.5 text-[10px] font-medium transition-colors ${
unit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
}`}
>{u === 'day' ? '天' : '月'}</button>
))}
</div>
) : (
<span className="text-[10px] text-muted"></span>
)}
</div>
<button
onClick={handleFetch}
disabled={!hasMinuteCap || isRunning || extend.isPending}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
>
{extend.isPending ? (
<><Loader2 className="h-3 w-3 animate-spin" /></>
) : (
<></>
)}
</button>
{confirmOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmOpen(false)} />
<div className="relative rounded-card border border-border bg-surface shadow-2xl mx-4 px-6 py-5 max-w-sm w-full space-y-4">
<div className="text-sm text-foreground text-center">K数据 {value} {unit === 'month' ? '月' : '天'}K</div>
<div className="flex items-center justify-center gap-3">
<button
onClick={() => { setConfirmOpen(false); extend.mutate() }}
disabled={extend.isPending}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors duration-150"
>
</button>
<button
onClick={() => setConfirmOpen(false)}
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors duration-150"
>
</button>
</div>
</div>
</div>
)}
</>
)
}
@@ -0,0 +1,165 @@
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Activity, Settings } from 'lucide-react'
import { Skeleton } from './Skeleton'
export function QuoteConfigCard({ enabled, running, isTrading, lastFetchMs, intervalS, intervalMin, intervalMax, loading, onToggle, toggling, showIntervalEdit, onShowIntervalEdit, onIntervalChange }: {
enabled: boolean
running: boolean
isTrading: boolean
lastFetchMs: number | null
intervalS: number
intervalMin: number
intervalMax: number
loading: boolean
onToggle: (enabled: boolean) => void
toggling: boolean
showIntervalEdit: boolean
onShowIntervalEdit: () => void
onIntervalChange: (v: number) => void
}) {
const statusColor = running && isTrading
? 'bg-accent shadow-[0_0_6px_rgba(61,214,140,0.5)]'
: enabled && running
? 'bg-warning/60'
: 'bg-muted'
const statusText = !enabled
? '已关闭'
: !isTrading
? '非交易时段'
: running
? '行情运行中'
: '已停止'
const lastFetchTime = lastFetchMs
? new Date(lastFetchMs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
: null
return (
<div className="rounded-card border border-border bg-surface p-4 relative">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<button
onClick={() => onToggle(!enabled)}
disabled={toggling}
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
enabled
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
: 'bg-elevated'
} ${toggling ? 'opacity-50' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
enabled ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
{loading ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><Skeleton w="w-8" /><Skeleton w="w-16" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-20" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-10" /><Skeleton w="w-14" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-12" /></div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<div className="flex items-center gap-1.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${statusColor} ${running && isTrading ? 'animate-pulse' : ''}`} />
<span className="font-mono text-secondary">{statusText}</span>
</div>
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className={`font-mono ${isTrading ? 'text-accent' : 'text-muted'}`}>{isTrading ? '交易中' : '休市'}</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted"></span>
<button
onClick={() => onShowIntervalEdit()}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showIntervalEdit ? 'text-accent' : 'text-secondary'}`}
title="设置轮询间隔"
>
<Settings className="h-3 w-3" />
</button>
</div>
<span className="font-mono text-secondary">{intervalS}s</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-secondary">{lastFetchTime ?? '—'}</span>
</div>
</div>
)}
<AnimatePresence>
{showIntervalEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<IntervalEditor
min={intervalMin}
max={intervalMax}
value={intervalS}
onChange={onIntervalChange}
/>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function IntervalEditor({ min, max, value, onChange }: {
min: number; max: number; value: number; onChange: (v: number) => void
}) {
const [draft, setDraft] = useState(value)
const clamped = Math.max(min, Math.min(max, draft))
const step = min < 1 ? 0.1 : min < 3 ? 0.5 : 1
const presets = min <= 3 ? [3, 5, 10, 30, 60] : [5, 10, 15, 30, 60]
return (
<div className="mt-2 pt-2 border-t border-border/50">
<div className="text-[10px] text-muted mb-1.5">
<span className="text-muted/60">({min}s ~ {max}s)</span>
</div>
<div className="flex flex-wrap gap-1 mb-2">
{presets.map(p => (
<button
key={p}
onClick={() => { setDraft(p); onChange(p) }}
className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors ${
Math.abs(clamped - p) < 0.01
? 'bg-accent/15 text-accent border border-accent/30'
: 'bg-elevated text-secondary hover:text-foreground border border-transparent'
}`}
>
{p}s
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="range"
min={min} max={max} step={step}
value={clamped}
onChange={e => { const v = parseFloat(e.target.value); setDraft(v); onChange(v) }}
className="flex-1 h-1 accent-accent cursor-pointer"
/>
<span className="text-[10px] font-mono text-foreground w-8 text-right">
{clamped < 1 ? clamped.toFixed(1) : clamped.toFixed(0)}s
</span>
</div>
</div>
)
}
@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react'
export function ScheduleEditor({ value, onSave, loading, hint }: {
value: { hour: number; minute: number }
onSave: (hour: number, minute: number) => void
loading: boolean
hint?: string
}) {
const [h, setH] = useState(value.hour)
const [m, setM] = useState(value.minute)
useEffect(() => { setH(value.hour); setM(value.minute) }, [value.hour, value.minute])
const handleSave = () => {
if (h === value.hour && m === value.minute) return
onSave(h, m)
}
return (
<div className="flex items-center gap-2 pt-1.5">
<span className="text-[10px] text-muted"></span>
<input
type="number" min={0} max={23} value={h}
onChange={e => setH(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"
/>
<span className="text-xs text-muted">:</span>
<input
type="number" min={0} max={59} value={m}
onChange={e => setM(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"
/>
<button
onClick={handleSave}
disabled={loading || (h === value.hour && m === value.minute)}
className="px-2.5 py-1 rounded-btn bg-accent/15 text-accent text-[11px] font-medium hover:bg-accent/25 disabled:opacity-40 transition-colors"
>
{loading ? '保存中…' : '保存'}
</button>
<span className="text-[10px] text-muted">{hint ? ` · ${hint}` : ''}</span>
</div>
)
}
@@ -0,0 +1,102 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useQuery } from '@tanstack/react-query'
import { api, type EnrichedField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
const TABLE_TITLES: Record<string, string> = {
instruments: '标的维表',
daily: '日 K',
adj_factor: '除权因子',
enriched: 'Enriched',
minute: '分钟 K',
index_instruments: '指数维表',
index_daily: '指数日 K',
index_enriched: '指数 Enriched',
}
function categorize(name: string): string {
if (['symbol', 'date'].includes(name)) return '基础'
if (['open', 'high', 'low', 'close', 'volume', 'amount'].includes(name)) return '行情'
if (name.startsWith('raw_') || name.startsWith('ex_') || name === 'close_pre_adj') return '复权'
if (name.startsWith('ma')) return '均线 MA'
if (name.startsWith('ema')) return '指数均线 EMA'
if (name.startsWith('macd')) return 'MACD'
if (name.startsWith('boll')) return '布林带'
if (name.startsWith('kdj')) return 'KDJ'
if (name.startsWith('rsi')) return 'RSI'
if (name.startsWith('signal_') || name.startsWith('consecutive_')) return '信号'
if (['atr_14', 'vol_ratio_5d', 'vol_ma5', 'vol_ma10', 'momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d', 'annual_vol_20d', 'change_pct', 'change_amount', 'amplitude', 'turnover_rate'].includes(name)) return '波动/动量'
if (['high_60d', 'low_60d'].includes(name)) return '极值'
return '其他'
}
export function EnrichedSchemaModal({ table, onClose }: { table: string | null; onClose: () => void }) {
const open = !!table
const schema = useQuery({
queryKey: QK.tableSchema(table!),
queryFn: () => api.enrichedSchema(table!),
enabled: open,
staleTime: Infinity,
})
const fields = schema.data ?? []
const groups: Record<string, EnrichedField[]> = {}
for (const f of fields) {
const cat = categorize(f.name)
if (!groups[cat]) groups[cat] = []
groups[cat].push(f)
}
const title = table ? (TABLE_TITLES[table] ?? table) : ''
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<motion.div
className="relative w-full max-w-xl max-h-[70vh] rounded-card border border-border bg-surface shadow-xl overflow-hidden mx-4"
initial={{ opacity: 0, scale: 0.95, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground">{title} </h3>
<span className="text-[10px] text-muted font-mono">{fields.length} </span>
</div>
<div className="px-5 py-3 overflow-y-auto max-h-[calc(70vh-48px)]">
{schema.isLoading ? (
<div className="text-xs text-muted animate-pulse py-4 text-center"></div>
) : (
<div className="space-y-3">
{Object.entries(groups).map(([cat, items]) => (
<div key={cat}>
<div className="text-[10px] font-medium text-accent/70 uppercase tracking-wider mb-1.5">{cat}</div>
<div className="space-y-1">
{items.map((f) => (
<div key={f.name} className="flex items-baseline gap-2 text-[11px]">
<span className="font-mono text-foreground shrink-0 min-w-[160px]">{f.name}</span>
<span className="text-secondary">{f.desc}</span>
<span className="text-muted ml-auto shrink-0">{f.type}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,61 @@
import { CheckCircle2, XCircle, Loader2, AlertCircle } from 'lucide-react'
import { formatDuration } from '@/lib/format'
export function SectionTitle({ icon: Icon, children }: { icon: React.ComponentType<{ className?: string }>; children: React.ReactNode }) {
return (
<h2 className="flex items-center gap-2 text-xs font-medium uppercase tracking-widest text-secondary">
<Icon className="h-3.5 w-3.5" />
{children}
</h2>
)
}
export function HistoryRow({ job, onClick }: { job: any; onClick: () => void }) {
const statusIcon = {
succeeded: { icon: CheckCircle2, color: 'text-bear' },
failed: { icon: XCircle, color: 'text-danger' },
running: { icon: Loader2, color: 'text-accent', spinning: true },
pending: { icon: Loader2, color: 'text-muted', spinning: true },
}[job.status as 'succeeded'] ?? { icon: AlertCircle, color: 'text-muted' }
const Icon = statusIcon.icon
return (
<button
onClick={onClick}
className="w-full px-5 py-3 hover:bg-elevated/50 transition-colors duration-150 ease-smooth text-left flex items-center justify-between gap-4"
>
<div className="flex items-center gap-3 min-w-0">
<Icon className={`h-4 w-4 shrink-0 ${statusIcon.color} ${(statusIcon as any).spinning ? 'animate-spin' : ''}`} />
<div className="min-w-0">
<div className="font-mono text-xs text-foreground">{job.id}</div>
<div className="text-[11px] text-muted">
{job.started_at ? new Date(job.started_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
{' · '}
{job.duration_s != null ? formatDuration(job.duration_s) : '...'}
</div>
</div>
</div>
<div className="text-right shrink-0">
{job.result && (() => {
const r = job.result as Record<string, any>
const parts: string[] = []
if (r.daily_days != null) parts.push(`日K ${r.daily_days}`)
if (r.enriched_days != null) parts.push(`enriched ${r.enriched_days}`)
if (r.minute_rows != null) parts.push(`分钟K ${r.minute_rows}`)
if (r.earliest_after && r.earliest_before) {
const a = String(r.earliest_after).slice(0, 10)
const b = String(r.earliest_before).slice(0, 10)
const days = r.daily_days ?? r.minute_days ?? 0
parts.push(days <= 1 ? a : `${a}~${b}`)
}
return parts.length > 0 ? (
<div className="text-xs text-secondary font-mono">{parts.join(' · ')}</div>
) : null
})()}
{job.error && (
<div className="text-xs text-danger truncate max-w-xs">{job.error}</div>
)}
</div>
</button>
)
}
@@ -0,0 +1,27 @@
import { motion } from 'framer-motion'
import { X } from 'lucide-react'
export function SettingsModal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<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 rounded-card border border-border bg-surface shadow-2xl mx-4 w-full max-w-md overflow-hidden"
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<button onClick={onClose} className="p-0.5 rounded hover:bg-elevated text-secondary">
<X className="h-4 w-4" />
</button>
</div>
<div className="p-5">
{children}
</div>
</motion.div>
</div>
)
}
@@ -0,0 +1,7 @@
export function Skeleton({ w = 'w-full', h = 'h-3.5', rounded = 'rounded-sm', className = '' }: {
w?: string; h?: string; rounded?: string; className?: string
}) {
return (
<div className={`${w} ${h} ${rounded} bg-elevated/60 animate-pulse ${className}`} />
)
}
+316
View File
@@ -0,0 +1,316 @@
import { motion } from 'framer-motion'
import { Loader2, CheckCircle2, Settings, Table2 } from 'lucide-react'
import { formatNumber } from '@/lib/format'
import { fmtDate } from '@/lib/format'
import { Skeleton } from './Skeleton'
// 卡片能力定义:capKey → 查 capability limitstierReq → 无权限时显示的档位要求
// capKey 为空串表示该数据在 free-api 服务器(无档/免费档)即可获取,无需付费能力门控。
export const CARD_META: Record<string, {
capKey: string // 对应的 capability key,空串表示本地计算 / free 服务器可用
tierReq: string // 最低档位要求(无权限时显示)
}> = {
// 标的维表走 exchanges 端点,free-api 服务器即可获取,无需付费能力
instruments: { capKey: '', tierReq: '' },
daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' },
adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' },
enriched: { capKey: '', tierReq: '' },
minute: { capKey: 'kline.minute.batch', tierReq: 'Pro+' },
financials: { capKey: 'financial', tierReq: 'Expert' },
}
export function Pill({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-btn bg-base/40 border border-border px-3 py-1.5">
<div className="text-[10px] uppercase tracking-wider text-muted">{label}</div>
<div className="font-mono text-sm font-medium tabular-nums mt-0.5">{value}</div>
</div>
)
}
function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix }: {
hasCap: boolean
isLocal: boolean
tierLabel?: string
tierReq?: string
capInfo?: { rpm: number | null; batch: number | null; subscribe: number | null } | undefined
localSuffix?: string
}) {
if (isLocal) {
return (
<span className="text-[10px] text-secondary bg-elevated rounded px-1.5 py-px font-medium">
{localSuffix ? ` · ${localSuffix}` : ''}
</span>
)
}
if (hasCap && capInfo && tierLabel) {
const parts = [tierLabel, `${capInfo.rpm}/min`]
if (capInfo.batch != null && capInfo.batch > 1) parts.push(`${capInfo.batch}股/批`)
return (
<span className="text-[10px] text-accent/80 bg-accent/8 rounded px-1.5 py-px font-mono font-medium">
{parts.join(' · ')}
</span>
)
}
if (!hasCap && tierReq && tierReq !== 'Free') {
// 缺权限且非 Free 档(付费档位才提示升级);Free 档人人可用,
// 若显示"需 Free"会造成 Expert 等用户困惑(通常是探测瞬时失败丢能力)
return (
<span className="text-[10px] text-warning/90 bg-warning/8 rounded px-1.5 py-px font-medium">
{tierReq}
</span>
)
}
if (hasCap) {
return (
<span className="text-[10px] text-accent/80 bg-accent/8 rounded px-1.5 py-px font-medium">
{tierLabel ?? '已授权'}
</span>
)
}
return null
}
export type FieldTab = { label: string; table: string }
export function StatCard({
title, hint, stats, isInstrument = false, loading = false,
active = false, done = false, skipped = false, stagePct = 0,
tierKey, capLimits, tierLabel,
auto, onSettings, onShowFields, settingsOpen, subLabel, localBadgeSuffix, fieldTabs,
}: {
title: string
hint: string
stats: any | null | undefined
isInstrument?: boolean
loading?: boolean
active?: boolean
done?: boolean
skipped?: boolean
stagePct?: number
tierKey?: string
capLimits?: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }>
tierLabel?: string
onSettings?: () => void
onShowFields?: (table?: string) => void
settingsOpen?: boolean
auto?: boolean
subLabel?: string
localBadgeSuffix?: string
// 多表字段入口: [{label: '维表', table: 'index_instruments'}, ...]
// 提供时渲染多个图标按钮(每个对应一张表的字段说明); 否则回退到单个 onShowFields
fieldTabs?: FieldTab[]
}) {
const empty = loading || !stats || (stats.rows === 0 && !stats.trading_days && !stats.fields)
const borderCls = active
? 'border-accent/50'
: done
? 'border-bear/30'
: 'border-border'
const bgCls = active ? 'bg-accent/[0.03]' : 'bg-surface'
const meta = tierKey ? CARD_META[tierKey] : undefined
const isLocal = meta?.capKey === ''
const capInfo = meta?.capKey ? capLimits?.[meta.capKey] : undefined
const hasCap = isLocal || !!capInfo
// 渲染字段说明入口图标
// - fieldTabs 提供时: 返回 null (图标由 renderSubLabelInline 内联到文字后)
// - 否则: 单个图标按钮 (onShowFields)
const renderFieldButtons = () => {
if (fieldTabs && fieldTabs.length > 0) return null
if (onShowFields) {
return (
<button
onClick={(e) => { e.stopPropagation(); onShowFields() }}
className="inline-flex align-middle ml-1 p-0.5 rounded hover:bg-elevated transition-colors text-secondary hover:text-accent"
title="查看字段说明"
>
<Table2 className="h-3 w-3" />
</button>
)
}
return null
}
// 单个图标按钮 (复用样式)
const fieldIconButton = (tab: FieldTab) => (
<button
key={tab.table}
onClick={(e) => { e.stopPropagation(); onShowFields?.(tab.table) }}
className="inline-flex align-middle -mt-px p-0.5 rounded hover:bg-elevated transition-colors text-secondary hover:text-accent"
title={`查看${tab.label}字段说明`}
>
<Table2 className="h-3 w-3" />
</button>
)
// subLabel 文本内容 (不含图标)
const subLabelText = subLabel
?? (isInstrument
? `标的 · ${((stats?.named ?? stats?.rows) ?? 0).toLocaleString()} 个含名称`
: stats?.fields
? '字段 · 复权 · 技术指标'
: title === '日 K' && stats?.trading_days
? '日 · A股标的 · 日线'
: stats?.trading_days && !stats?.rows
? '日 · A股标的 · 分钟级'
: (() => {
const parts = [`行 · ${(stats?.symbols_covered ?? 0)} 只标的`]
if (stats?.trading_days) parts.push(`· ${stats.trading_days}`)
return parts.join(' ')
})())
// 有 fieldTabs 时: 把 subLabel 按分隔符拆开, 每个匹配词后面内联图标
// 例如 "日 · 维表 · 日K · 指标" → 日 · 维表[icon] · 日K[icon] · 指标[icon]
const renderSubLabelInline = () => {
if (!fieldTabs || fieldTabs.length === 0) {
return <>{subLabelText}{renderFieldButtons()}</>
}
const labels = fieldTabs.map(t => t.label)
// 按非字母数字汉字的分隔符拆分, 保留分隔符
const tokens = subLabelText.split(/(\s*·\s*|\s+)/).filter(t => t !== '')
const used = new Set<string>()
return (
<>
{tokens.map((tok, i) => {
const trimmed = tok.trim()
// 跳过纯分隔符
if (trimmed === '' || trimmed === '·') return <span key={i}>{tok}</span>
// 匹配某个 tab label (整体匹配, 避免部分子串误命中)
const idx = labels.indexOf(trimmed)
if (idx >= 0 && !used.has(trimmed)) {
used.add(trimmed)
return <span key={i}>{tok}{fieldIconButton(fieldTabs[idx])}</span>
}
return <span key={i}>{tok}</span>
})}
</>
)
}
return (
<div className={`rounded-card border ${borderCls} ${bgCls} flex flex-col transition-all duration-300 ${active ? 'shadow-[0_0_16px_rgba(61,214,140,0.08)]' : ''}`}>
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<div className="flex items-center gap-1.5">
{auto !== undefined && !loading && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${auto ? 'bg-accent shadow-[0_0_4px_rgba(61,214,140,0.5)]' : 'bg-muted'}`} />
<span className={auto ? 'text-accent/70' : 'text-muted'}>{auto ? '自动' : '关闭'}</span>
</span>
)}
{active && <Loader2 className="h-3.5 w-3.5 text-accent animate-spin" />}
{done && !active && !skipped && <CheckCircle2 className="h-3.5 w-3.5 text-bear" />}
{skipped && !active && (
<span className="text-[10px] text-muted bg-elevated rounded px-1.5 py-px font-medium">
</span>
)}
{onSettings && (
<button
onClick={(e) => { e.stopPropagation(); onSettings() }}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${
settingsOpen ? 'text-accent' : 'text-secondary'
}`}
>
<Settings className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="px-4 pb-1 text-[10px] text-muted">{hint}</div>
<div className="px-4 pb-2">
{loading ? (
<Skeleton w="w-16" h="h-4" />
) : (
<CapBadge
hasCap={hasCap}
isLocal={isLocal}
tierLabel={tierLabel}
tierReq={meta?.tierReq}
capInfo={capInfo}
localSuffix={localBadgeSuffix}
/>
)}
</div>
<div className="px-4 pb-1">
{loading ? (
<>
<Skeleton w="w-20" h="h-8" />
<Skeleton w="w-24" h="h-3" className="mt-1" />
</>
) : empty ? (
<>
<div className="font-mono text-2xl font-bold tracking-tight tabular-nums text-foreground"></div>
<div className="text-[11px] text-muted mt-0.5">
{renderFieldButtons()}
</div>
</>
) : (
<>
<div className="font-mono text-2xl font-bold tracking-tight tabular-nums text-foreground">
{stats.fields
? stats.fields
: stats.trading_days && !stats.rows
? stats.trading_days.toLocaleString()
: formatNumber(stats.rows)}
</div>
<div className="text-[11px] text-muted mt-0.5">
{renderSubLabelInline()}
</div>
</>
)}
</div>
<div className="mt-auto px-4 pb-4 pt-2 border-t border-border space-y-0.5">
{loading ? (
<>
<div className="flex justify-between"><Skeleton w="w-6" h="h-3" /><Skeleton w="w-16" h="h-3" /></div>
<div className="flex justify-between"><Skeleton w="w-4" h="h-3" /><Skeleton w="w-16" h="h-3" /></div>
</>
) : empty ? (
<>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '快照日' : '起'}</span>
<span className="font-mono text-secondary"></span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '标的数' : '止'}</span>
<span className="font-mono text-secondary"></span>
</div>
</>
) : (
<>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '快照日' : '起'}</span>
<span className="font-mono text-secondary">{fmtDate(isInstrument ? stats.latest_as_of : stats.earliest_date)}</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '标的数' : '止'}</span>
<span className="font-mono text-secondary">{isInstrument ? String(stats.rows) : fmtDate(stats.latest_date)}</span>
</div>
</>
)}
</div>
{active && stagePct > 0 && (
<div className="h-1 bg-elevated overflow-hidden rounded-b-card">
<motion.div
className="h-full bg-accent"
initial={{ width: 0 }}
animate={{ width: `${stagePct}%` }}
transition={{ duration: 0.3, ease: 'easeOut' }}
/>
</div>
)}
</div>
)
}
@@ -0,0 +1,383 @@
import { useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { X, Loader2, Upload, Plus, AlertCircle, Tag, Clock } from 'lucide-react'
import { api, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
export function CreateExtDialog({ onClose }: { onClose: () => void }) {
const qc = useQueryClient()
const [id, setId] = useState('')
const [label, setLabel] = useState('')
const [description, setDescription] = useState('')
const [mode, setMode] = useState<'snapshot' | 'timeseries'>('snapshot')
const [fields, setFields] = useState<ExtDataField[]>([])
const [error, setError] = useState('')
const detectFileRef = useRef<HTMLInputElement>(null)
const [detecting, setDetecting] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [symbolMap, setSymbolMap] = useState<Record<string, string>>({})
const [codeMap, setCodeMap] = useState<Record<string, string>>({})
const [matchStatus, setMatchStatus] = useState<'none' | 'partial' | 'full'>('none')
const [selectMapping, setSelectMapping] = useState<{ fields: { name: string; dtype: string; label: string }[]; need: 'symbol' | 'code' | 'both' } | null>(null)
const create = useMutation({
mutationFn: () => {
const userF = fields.filter((f) => f.name.trim() && f.name !== 'symbol' && f.name !== 'code')
return api.extDataCreate({
id,
label,
mode,
fields: [
{ name: 'symbol', dtype: 'string', label: '标的代码' },
{ name: 'code', dtype: 'string', label: '代码' },
...userF,
],
description: description.trim() || undefined,
symbol_map: symbolMap,
code_map: codeMap,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.extData })
onClose()
},
onError: (err) => setError(String(err)),
})
const addField = () =>
setFields([...fields, { name: '', dtype: 'string', label: '' }])
const removeField = (i: number) =>
setFields(fields.filter((_, idx) => idx !== i))
const updateField = (i: number, key: keyof ExtDataField, val: string) =>
setFields(fields.map((f, idx) => (idx === i ? { ...f, [key]: val } : f)))
const valid = id.trim() && label.trim() && fields.some((f) => f.name.trim()) && matchStatus !== 'none'
const processDetection = (
detected: { name: string; dtype: string; label: string }[],
symCands: string[],
codeCands: string[],
) => {
let sm: Record<string, string> = {}
let cm: Record<string, string> = {}
let status: 'none' | 'partial' | 'full' = 'none'
if (symCands.length === 1 && codeCands.length === 1) {
sm = { type: 'mapped', col: symCands[0] }
cm = { type: 'mapped', col: codeCands[0] }
status = 'full'
} else if (symCands.length === 1 && codeCands.length === 0) {
sm = { type: 'mapped', col: symCands[0] }
cm = { type: 'computed', from: 'symbol', method: 'strip_exchange' }
status = 'full'
} else if (symCands.length === 0 && codeCands.length === 1) {
cm = { type: 'mapped', col: codeCands[0] }
sm = { type: 'computed', from: 'code', method: 'append_exchange' }
status = 'full'
} else if (symCands.length > 1 || codeCands.length > 1) {
setSelectMapping({
fields: detected,
need: symCands.length > 0 && codeCands.length > 0 ? 'both' : symCands.length > 0 ? 'symbol' : 'code',
})
setFields(detected)
return
} else {
setSelectMapping({ fields: detected, need: 'both' })
setFields(detected)
return
}
setSymbolMap(sm)
setCodeMap(cm)
setMatchStatus(status)
setFields(detected)
setSelectMapping(null)
}
const handleDetectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
setDetecting(true); setError(''); setSelectMapping(null)
api.extDataDetectFields(file)
.then((res) => {
processDetection(res.fields, res.symbol_candidates, res.code_candidates)
})
.catch((err) => setError(String(err)))
.finally(() => setDetecting(false))
}
const userFields = fields.filter(f => f.name !== 'symbol' && f.name !== 'code')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<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 rounded-2xl border border-border bg-surface shadow-2xl mx-4 w-full max-w-xl max-h-[85vh] flex flex-col overflow-hidden"
>
<div className="px-6 pt-5 pb-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-foreground"></h3>
<p className="text-[11px] mt-1 inline-flex items-center gap-1 bg-amber-500/10 text-amber-400 px-2 py-0.5 rounded-md font-medium">
CSV/Excel
</p>
</div>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-elevated text-secondary transition-colors">
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 pb-6 space-y-5">
<div>
<div className="text-[11px] font-medium text-secondary mb-2"></div>
<div className="grid grid-cols-2 gap-2">
{(['snapshot', 'timeseries'] as const).map((m) => {
const active = mode === m
return (
<button
key={m}
onClick={() => setMode(m)}
className={`relative flex items-start gap-3 px-4 py-3 rounded-xl border transition-all duration-200 text-left ${
active
? 'border-amber-500/40 bg-amber-500/[0.08] shadow-sm shadow-amber-500/10'
: 'border-border bg-elevated/30 hover:bg-elevated/60'
}`}
>
<div className={`mt-0.5 p-1.5 rounded-lg ${
active
? 'bg-amber-500/15 text-amber-400'
: 'bg-elevated text-muted'
}`}>
{m === 'snapshot' ? <Tag className="h-4 w-4" /> : <Clock className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<div className={`text-xs font-medium ${active ? 'text-foreground' : 'text-secondary'}`}>
{m === 'snapshot' ? '快照型' : '时序型'}
</div>
<div className="text-[10px] text-muted mt-0.5 leading-relaxed">
{m === 'snapshot' ? '每个标的一条,如概念、行业、人气排名' : '按日期记录,如资金流、情绪指数'}
</div>
</div>
</button>
)
})}
</div>
</div>
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div>
<div className="text-[11px] font-medium text-secondary mb-1.5"></div>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={mode === 'snapshot' ? '例: 概念' : '例: 资金流'}
className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow"
/>
</div>
<div>
<div className="text-[11px] font-medium text-secondary mb-1.5"></div>
<input
value={id}
onChange={(e) => setId(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder={mode === 'snapshot' ? '例: concept' : '例: money_flow'}
className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground font-mono placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow"
/>
</div>
</div>
<div>
<div className="text-[11px] font-medium text-secondary mb-1.5"></div>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="扩展数据 · 与标的 JOIN(可选自定义描述)"
className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow"
/>
</div>
<div>
<div className="text-[11px] font-medium text-secondary mb-2"></div>
<input
ref={detectFileRef}
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"
onChange={handleDetectFile}
/>
{selectMapping && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mb-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.06] px-3 py-2.5 space-y-2"
>
<div className="text-[10px] text-amber-400 font-medium">
symbol
</div>
<div className="flex flex-wrap gap-1.5">
{selectMapping.fields.map(f => (
<button
key={f.name}
onClick={() => {
const sm = { type: 'mapped', col: f.name }
const cm = { type: 'computed', from: 'symbol', method: 'strip_exchange' }
setSymbolMap(sm)
setCodeMap(cm)
setMatchStatus('full')
setSelectMapping(null)
}}
className="px-2.5 py-1 rounded-md bg-accent/10 text-accent text-[10px] font-medium hover:bg-accent/20 transition-colors"
>
{f.name}
</button>
))}
<button
onClick={() => setSelectMapping(null)}
className="px-2.5 py-1 rounded-md bg-elevated text-muted text-[10px] hover:text-secondary transition-colors"
>
</button>
</div>
</motion.div>
)}
{userFields.length === 0 ? (
<div
onClick={() => detectFileRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={e => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) { setDetecting(true); setError(''); setSelectMapping(null); api.extDataDetectFields(file).then(res => { processDetection(res.fields, res.symbol_candidates, res.code_candidates); }).catch(err => setError(String(err))).finally(() => setDetecting(false)) }
}}
className={`rounded-xl border-2 border-dashed py-6 flex flex-col items-center justify-center gap-2 cursor-pointer transition-colors ${
dragOver ? 'border-accent bg-accent/[0.06]' : detecting ? 'border-border/40 pointer-events-none' : 'border-border/30 hover:border-accent/40 hover:bg-accent/[0.02]'
}`}
>
{detecting ? (
<><Loader2 className="h-5 w-5 text-accent animate-spin" /><span className="text-[11px] text-muted"></span></>
) : (
<>
<Upload className="h-5 w-5 text-muted/60" />
<span className="text-[11px] text-secondary">CSV / Excel</span>
<span className="text-[10px] text-amber-400/70">symbol </span>
</>
)}
</div>
) : (
<>
<div className="flex items-center justify-end gap-1.5 mb-1.5">
<button
onClick={() => detectFileRef.current?.click()}
disabled={detecting}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] text-muted hover:text-accent hover:bg-accent/[0.06] disabled:opacity-40 transition-colors"
>
{detecting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Upload className="h-3 w-3" />}
</button>
<button
onClick={addField}
className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-md text-[10px] text-muted hover:text-accent hover:bg-accent/[0.06] transition-colors"
>
<Plus className="h-3 w-3" />
</button>
</div>
<div className="space-y-1">
<div className={`flex items-center gap-2 px-2.5 py-1.5 rounded-md border ${matchStatus !== 'none' ? 'border-border/40 bg-elevated/20' : 'border-danger/30 bg-danger/[0.04]'}`}>
<span className="w-[72px] shrink-0 text-[11px] text-muted"></span>
<span className="flex-1 text-[11px] font-mono text-muted">symbol</span>
<span className="w-[52px] text-center text-[10px] text-muted/40"></span>
{matchStatus !== 'none'
? <span className="text-[9px] text-green-500/70 shrink-0">
{symbolMap.type === 'mapped' ? `${symbolMap.col}` : '← 计算'}
</span>
: <AlertCircle className="h-3.5 w-3.5 text-danger/60 shrink-0" />}
</div>
<div className={`flex items-center gap-2 px-2.5 py-1.5 rounded-md border ${matchStatus !== 'none' ? 'border-border/40 bg-elevated/20' : 'border-danger/30 bg-danger/[0.04]'}`}>
<span className="w-[72px] shrink-0 text-[11px] text-muted"></span>
<span className="flex-1 text-[11px] font-mono text-muted">code</span>
<span className="w-[52px] text-center text-[10px] text-muted/40"></span>
{matchStatus !== 'none'
? <span className="text-[9px] text-green-500/70 shrink-0">
{codeMap.type === 'mapped' ? `${codeMap.col}` : codeMap.method === 'strip_exchange' ? '← symbol截取' : '← 推算'}
</span>
: <AlertCircle className="h-3.5 w-3.5 text-danger/60 shrink-0" />}
</div>
{userFields.map((f) => {
const idx = fields.indexOf(f)
return (
<div key={idx} className="flex items-center gap-1.5 group">
<input
value={f.label}
onChange={(e) => updateField(idx, 'label', e.target.value)}
placeholder="显示名"
className="w-[72px] h-7 px-2 rounded-md border border-border bg-base text-[11px] text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/40"
/>
<input
value={f.name}
onChange={(e) => updateField(idx, 'name', e.target.value)}
placeholder="字段名"
className="flex-1 h-7 px-2 rounded-md border border-border bg-base text-[11px] font-mono text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/40"
/>
<select
value={f.dtype}
onChange={(e) => updateField(idx, 'dtype', e.target.value)}
className="h-7 px-2 rounded-md border border-border bg-base text-[11px] text-foreground"
>
<option value="string"></option>
<option value="int"></option>
<option value="float"></option>
<option value="bool"></option>
</select>
<button
onClick={() => removeField(idx)}
className="p-1 rounded text-muted/40 hover:text-danger hover:bg-danger/10 opacity-0 group-hover:opacity-100 transition-all"
>
<X className="h-3 w-3" />
</button>
</div>
)
})}
</div>
</>
)}
</div>
{error && (
<div className="text-[11px] text-danger bg-danger/5 rounded-lg px-3 py-2">{error}</div>
)}
</div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border/50 bg-elevated/20">
<div className="text-[10px] text-muted/60"></div>
<div className="flex items-center gap-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors">
</button>
<button
onClick={() => create.mutate()}
disabled={!valid || create.isPending}
className="px-5 py-2 rounded-lg bg-accent text-base text-xs font-medium hover:bg-accent/90 disabled:opacity-40 transition-colors shadow-sm shadow-accent/20"
>
{create.isPending ? '创建中…' : '创建'}
</button>
</div>
</div>
</motion.div>
</div>
)
}
@@ -0,0 +1,252 @@
import { useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { X, Loader2, Upload } from 'lucide-react'
import { api, type ExtDataConfig, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
export function EditExtDialog({ config, onClose }: { config: ExtDataConfig; onClose: () => void }) {
const qc = useQueryClient()
const [label, setLabel] = useState(config.label)
const [description, setDescription] = useState(config.description ?? '')
const [fields, setFields] = useState<ExtDataField[]>([...config.fields])
const [error, setError] = useState('')
const detectFileRef = useRef<HTMLInputElement>(null)
const [detecting, setDetecting] = useState(false)
const [symbolMapping, setSymbolMapping] = useState<{ candidates: string[]; file: File } | null>(null)
const update = useMutation({
mutationFn: () =>
api.extDataUpdate(config.id, {
label: label.trim(),
fields: fields.filter((f) => f.name.trim()),
description: description.trim() || '',
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.extData })
onClose()
},
onError: (err) => setError(String(err)),
})
const addField = () =>
setFields([...fields, { name: '', dtype: 'string', label: '' }])
const removeField = (i: number) =>
setFields(fields.filter((_, idx) => idx !== i))
const updateField = (i: number, key: keyof ExtDataField, val: string) =>
setFields(fields.map((f, idx) => (idx === i ? { ...f, [key]: val } : f)))
const valid = label.trim() && fields.some((f) => f.name.trim())
const applyDetectedFields = (detected: { name: string; dtype: string; label: string }[]) => {
const rest = detected.filter(f => f.name !== 'symbol')
setFields([
{ name: 'symbol', dtype: 'string', label: '标的代码' },
...rest,
])
}
const handleDetectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
setDetecting(true); setError(''); setSymbolMapping(null)
api.extDataDetectFields(file)
.then((res) => {
const hasSym = res.symbol_candidates.length > 0
const hasCode = res.code_candidates.length > 0
if (hasSym || hasCode) {
applyDetectedFields(res.fields)
} else {
const candidates = res.fields.map(f => f.name)
setSymbolMapping({ candidates, file })
}
})
.catch((err) => setError(String(err)))
.finally(() => setDetecting(false))
}
const handleSymbolMap = (_col: string) => {
if (!symbolMapping) return
setDetecting(true); setError('')
const rest = fields.filter(f => f.name !== 'symbol')
setFields([{ name: 'symbol', dtype: 'string', label: '标的代码' }, ...rest])
setSymbolMapping(null)
setDetecting(false)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<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 rounded-2xl border border-border bg-surface shadow-2xl mx-4 w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden"
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground"></h3>
<button onClick={onClose} className="p-0.5 rounded hover:bg-elevated text-secondary">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div>
<label className="text-[10px] text-muted mb-1 block"></label>
<div className="h-8 px-3 rounded-btn bg-elevated/50 border border-border text-xs text-muted font-mono flex items-center">
{config.id}
</div>
</div>
<div>
<label className="text-[10px] text-muted mb-1 block"></label>
<div className={`h-8 px-3 rounded-btn border text-xs flex items-center ${
config.mode === 'snapshot'
? 'bg-blue-500/10 border-blue-500/30 text-blue-400'
: 'bg-amber-500/10 border-amber-500/30 text-amber-400'
}`}>
{config.mode === 'snapshot' ? '快照型' : '时序型'}
</div>
</div>
</div>
<div>
<label className="text-[10px] text-muted mb-1 block"></label>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full h-8 px-3 rounded-btn bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="text-[10px] text-muted mb-1 block"></label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="可选,简要说明数据的用途"
className="w-full h-8 px-3 rounded-btn bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted"></label>
<div className="flex items-center gap-2">
<input
ref={detectFileRef}
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"
onChange={handleDetectFile}
/>
<button
onClick={() => detectFileRef.current?.click()}
disabled={detecting}
className="text-[10px] text-accent/80 hover:text-accent inline-flex items-center gap-0.5 disabled:opacity-40"
>
{detecting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Upload className="h-3 w-3" />}
</button>
<button onClick={addField} className="text-[10px] text-accent hover:text-accent/80">+ </button>
</div>
</div>
{symbolMapping && (
<div className="mb-2 rounded-md border border-amber-500/30 bg-amber-500/[0.06] px-3 py-2 space-y-1.5">
<div className="text-[10px] text-amber-400"> symbol </div>
<div className="flex flex-wrap gap-1.5">
{symbolMapping.candidates.map(col => (
<button
key={col}
onClick={() => handleSymbolMap(col)}
className="px-2 py-1 rounded-btn bg-accent/10 text-accent text-[10px] font-medium hover:bg-accent/20 transition-colors"
>
{col}
</button>
))}
<button
onClick={() => setSymbolMapping(null)}
className="px-2 py-1 rounded-btn bg-elevated text-muted text-[10px] hover:text-secondary transition-colors"
>
</button>
</div>
</div>
)}
<div className="space-y-2">
{fields.map((f, i) => {
const isBuiltin = f.name === 'symbol' || f.name === 'name'
return (
<div key={i} className="flex items-center gap-2">
<input
value={f.label}
onChange={(e) => updateField(i, 'label', e.target.value)}
placeholder="显示名"
disabled={isBuiltin}
className={`w-20 h-7 px-2 rounded-btn border text-[11px] text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50 ${
isBuiltin
? 'bg-elevated/50 border-border text-muted cursor-not-allowed'
: 'bg-base border-border'
}`}
/>
<input
value={f.name}
onChange={(e) => updateField(i, 'name', e.target.value)}
placeholder="字段名 (英文)"
disabled={isBuiltin}
className={`flex-1 h-7 px-2 rounded-btn border text-[11px] font-mono placeholder:text-muted/40 focus:outline-none focus:border-accent/50 ${
isBuiltin
? 'bg-elevated/50 border-border text-muted cursor-not-allowed'
: 'bg-base border-border'
}`}
/>
<select
value={f.dtype}
onChange={(e) => updateField(i, 'dtype', e.target.value)}
disabled={isBuiltin}
className={`h-7 px-2 rounded-btn border border-border text-[11px] text-foreground ${
isBuiltin ? 'bg-elevated/50 text-muted cursor-not-allowed' : 'bg-base'
}`}
>
<option value="string"></option>
<option value="int"></option>
<option value="float"></option>
<option value="bool"></option>
</select>
{!isBuiltin && fields.length > 3 && (
<button onClick={() => removeField(i)} className="p-1 text-muted hover:text-danger">
<X className="h-3 w-3" />
</button>
)}
{isBuiltin && <div className="w-[18px]" />}
</div>
)
})}
</div>
</div>
{error && (
<div className="text-xs text-danger bg-danger/5 rounded-btn px-3 py-1.5">{error}</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border">
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors">
</button>
<button
onClick={() => update.mutate()}
disabled={!valid || update.isPending}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
>
{update.isPending ? '保存中…' : '保存'}
</button>
</div>
</motion.div>
</div>
)
}
@@ -0,0 +1,79 @@
import { Check, Copy } from 'lucide-react'
import type { ExtDataConfig } from '@/lib/api'
export function ExtDataApiPanel({ config, copied, setCopied }: {
config: ExtDataConfig
copied: boolean
setCopied: (v: boolean) => void
}) {
const endpoint = `POST /api/ext-data/${config.id}/ingest`
const exampleRow: Record<string, unknown> = { symbol: '000001.SZ' }
for (const f of config.fields) {
if (f.name === 'symbol' || f.name === 'code') continue
exampleRow[f.name] = f.dtype === 'int' ? 100 : f.dtype === 'float' ? 1.5 : f.dtype === 'bool' ? true : '示例'
}
if (config.fields.some(f => f.name === 'code')) {
exampleRow['code'] = '000001'
}
const exampleBody = {
date: config.mode === 'timeseries' ? '2025-01-15' : undefined,
rows: [exampleRow],
}
const exampleJson = JSON.stringify(exampleBody, null, 2)
const curlCmd = `curl -X POST http://localhost:${window.location.port || '3018'}/api/ext-data/${config.id}/ingest \\
-H 'Content-Type: application/json' \\
-d '${JSON.stringify(exampleBody)}'`
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="space-y-2.5">
<div>
<div className="text-[10px] text-muted mb-1"></div>
<div className="flex items-center gap-1.5 bg-elevated rounded-md px-2.5 py-1.5">
<code className="text-[11px] font-mono text-accent flex-1 select-all">{endpoint}</code>
<button onClick={() => handleCopy(endpoint)} className="text-muted hover:text-secondary transition-colors">
{copied ? <Check className="h-3 w-3 text-accent" /> : <Copy className="h-3 w-3" />}
</button>
</div>
</div>
<div>
<div className="text-[10px] text-muted mb-1"></div>
<pre className="bg-elevated rounded-md px-2.5 py-2 text-[10px] font-mono text-secondary overflow-x-auto whitespace-pre leading-relaxed">
{exampleJson}
</pre>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted">cURL </span>
<button
onClick={() => handleCopy(curlCmd)}
className="text-[10px] text-accent hover:text-accent/80 flex items-center gap-0.5 transition-colors"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</button>
</div>
<pre className="bg-elevated rounded-md px-2.5 py-2 text-[10px] font-mono text-secondary overflow-x-auto whitespace-pre-wrap leading-relaxed break-all">
{curlCmd}
</pre>
</div>
<div className="text-[10px] text-muted leading-relaxed">
{config.mode === 'snapshot' ? (
<><span className="text-secondary">date</span> </>
) : (
<><span className="text-secondary">date</span> </>
)}
</div>
</div>
)
}
@@ -0,0 +1,212 @@
import { useState } from 'react'
import { Loader2, Search, RefreshCw, Check } from 'lucide-react'
import { api, type ExtDataConfig } from '@/lib/api'
export function ExtDataPullPanel({ config, onSaved }: {
config: ExtDataConfig
onSaved: () => void
}) {
const pull = config.pull
const [url, setUrl] = useState(pull?.url ?? '')
const [method, setMethod] = useState(pull?.method ?? 'GET')
const [headerStr, setHeaderStr] = useState(
pull?.headers ? JSON.stringify(pull.headers, null, 2) : ''
)
const [body, setBody] = useState(pull?.body ?? '')
const [responsePath, setResponsePath] = useState(pull?.response_path ?? '')
const [fieldMapStr, setFieldMapStr] = useState(
pull?.field_map ? JSON.stringify(pull.field_map, null, 2) : ''
)
const [schedule, setSchedule] = useState(pull?.schedule_minutes ?? 1440)
const [enabled, setEnabled] = useState(pull?.enabled ?? false)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [running, setRunning] = useState(false)
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, {
url, method, headers, body: body || undefined,
response_path: responsePath, field_map,
schedule_minutes: schedule, enabled,
}).then(() => onSaved())
.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))
.then(r => { setTestResult(r); onSaved() })
.catch(e => setError(e.message || '测试失败'))
.finally(() => setTesting(false))
}
const handleRun = () => {
setRunning(true); setError('')
api.extDataPullRun(config.id)
.then(() => onSaved())
.catch(e => setError(e.message || '执行失败'))
.finally(() => setRunning(false))
}
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>
<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>
{method === 'POST' && (
<div>
<div className="text-[10px] text-muted mb-0.5"> (JSON)</div>
<textarea
value={body} onChange={e => setBody(e.target.value)}
placeholder='{"page": 1}'
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="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"
/>
</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>
</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>
<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">
<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"
>
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Search className="h-3 w-3" />}
</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"
>
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</button>
</div>
</div>
{testResult && (
<div className="rounded-md 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>
</div>
{!testResult.has_symbol && (
<div className="text-[10px] text-amber-500"> symbol </div>
)}
{testResult.preview.length > 0 && (
<pre className="text-[9px] font-mono text-muted bg-elevated rounded px-2 py-1.5 overflow-x-auto max-h-32">
{JSON.stringify(testResult.preview, null, 2)}
</pre>
)}
</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>
</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,315 @@
import { useRef, useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { Settings, Tag, Upload, Code, RefreshCw, CheckCircle2, Loader2, Pencil, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
import { api, type ExtDataConfig } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { SettingsModal } from '@/components/data/SettingsModal'
import { ExtDataPullPanel } from './ExtDataPullPanel'
import { ExtDataApiPanel } from './ExtDataApiPanel'
export function ExtDataStatCard({ config, onDelete, deleting, onEdit }: {
config: ExtDataConfig
onDelete: () => void
deleting: boolean
onEdit?: () => void
}) {
const qc = useQueryClient()
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [uploadResult, setUploadResult] = useState<{ rows: number; date: string } | null>(null)
const [showDelete, setShowDelete] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [ingestTab, setIngestTab] = useState<'file' | 'api' | 'pull'>('file')
const [copied, setCopied] = useState(false)
const [fieldsExpanded, setFieldsExpanded] = useState(false)
const fieldsRef = useRef<HTMLDivElement>(null)
const [fieldsOverflow, setFieldsOverflow] = useState(false)
useEffect(() => {
const el = fieldsRef.current
if (!el) return
const prev = el.style.maxHeight
el.style.maxHeight = 'none'
const full = el.scrollHeight
el.style.maxHeight = prev
setFieldsOverflow(full > 68)
}, [config.fields])
const upload = useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => api.extDataUpload(id, file),
onMutate: () => { setUploading(true); setUploadResult(null) },
onSuccess: (data) => {
setUploadResult({ rows: data.rows, date: data.date })
qc.invalidateQueries({ queryKey: QK.extData })
qc.invalidateQueries({ queryKey: QK.dataStatus })
setTimeout(() => {
setUploadResult(null)
setSettingsOpen(false)
}, 1500)
},
onSettled: () => setUploading(false),
})
const doUpload = (file: File) => upload.mutate({ id: config.id, file })
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) doUpload(file)
e.target.value = ''
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) doUpload(file)
}
return (
<div className={`rounded-card border flex flex-col transition-all duration-300 ${
config.mode === 'snapshot'
? 'border-blue-500/30 bg-blue-500/[0.03]'
: 'border-amber-500/30 bg-amber-500/[0.03]'
}`}>
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<h3 className="text-sm font-medium text-foreground">{config.label}</h3>
<div className="flex items-center gap-1.5">
<span className={`text-[10px] px-1.5 py-px rounded font-medium ${
config.mode === 'snapshot'
? 'bg-blue-500/10 text-blue-400'
: 'bg-amber-500/10 text-amber-400'
}`}>
{config.mode === 'snapshot' ? '快照' : '时序'}
</span>
<button
onClick={() => setSettingsOpen(v => !v)}
className="p-0.5 rounded hover:bg-elevated transition-colors text-secondary"
>
<Settings className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="px-4 pb-1.5 text-[10px] text-muted/70 leading-relaxed line-clamp-2">
{config.description || `扩展数据 · ${config.mode === 'snapshot' ? '与标的 JOIN' : '与日K JOIN'}`}
</div>
<div className="flex-1 min-h-[81px] px-4 pb-2">
<div className={`relative overflow-hidden transition-all duration-300 ${fieldsExpanded ? '' : 'h-[75px]'}`}>
<div
ref={fieldsRef}
className="flex flex-wrap gap-1"
>
{config.fields
.filter(f => f.name !== 'symbol' && f.name !== 'code')
.map((f) => (
<span key={f.name} className="inline-flex items-center gap-1 text-[10px] bg-elevated rounded px-1.5 py-0.5">
<Tag className="h-2.5 w-2.5 text-muted" />
<span className="text-secondary">{f.label || f.name}</span>
</span>
))
}
</div>
{fieldsOverflow && !fieldsExpanded && (
<div
className="absolute bottom-0 inset-x-0 h-6 bg-gradient-to-t from-surface to-transparent cursor-pointer flex items-end justify-center"
onClick={() => setFieldsExpanded(true)}
>
<ChevronDown className="h-3 w-3 text-muted" />
</div>
)}
{fieldsExpanded && fieldsOverflow && (
<button
onClick={() => setFieldsExpanded(false)}
className="w-full flex items-center justify-center pt-1 text-[10px] text-muted hover:text-secondary transition-colors"
>
<ChevronUp className="h-3 w-3" />
</button>
)}
</div>
</div>
<div className="mt-auto px-4 pb-4 pt-2 border-t border-border/50">
<div className="flex justify-between text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-secondary">{config.id}</span>
</div>
<div className="flex justify-between text-[11px] mt-1">
<span className="text-muted"></span>
<span className="text-secondary">{config.latest_sync_date ?? '—'}</span>
</div>
</div>
<AnimatePresence>
{settingsOpen && (
<SettingsModal title={`${config.label} · 设置`} onClose={() => setSettingsOpen(false)}>
<div className="space-y-3">
{onEdit && (
<button
onClick={() => { setSettingsOpen(false); onEdit() }}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn border border-border bg-elevated text-foreground text-xs font-medium hover:bg-border/30 transition-colors"
>
<Pencil className="h-3 w-3" />
</button>
)}
<button
onClick={() => {
api.extDataFixSymbol(config.id).then((res) => {
setUploadResult({ rows: res.fixed_files, date: '格式修复完成' })
qc.invalidateQueries({ queryKey: QK.extData })
setTimeout(() => setUploadResult(null), 3000)
})
}}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn border border-border bg-elevated text-foreground text-xs font-medium hover:bg-border/30 transition-colors"
>
<Tag className="h-3 w-3" />
</button>
<div className="flex gap-1 rounded-lg bg-elevated/60 p-0.5">
<button
onClick={() => setIngestTab('file')}
className={`flex-1 inline-flex items-center justify-center gap-1 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
ingestTab === 'file' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
}`}
>
<Upload className="h-3 w-3" />
</button>
<button
onClick={() => setIngestTab('api')}
className={`flex-1 inline-flex items-center justify-center gap-1 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
ingestTab === 'api' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
}`}
>
<Code className="h-3 w-3" />
</button>
<button
onClick={() => setIngestTab('pull')}
className={`flex-1 inline-flex items-center justify-center gap-1 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
ingestTab === 'pull' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
}`}
>
<RefreshCw className="h-3 w-3" />
</button>
</div>
{ingestTab === 'file' ? (
<>
<input
ref={fileRef}
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"
onChange={handleFile}
/>
<div
onClick={() => fileRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={`relative cursor-pointer rounded-lg border-2 border-dashed transition-colors py-5 flex flex-col items-center justify-center gap-1.5 ${
dragOver
? 'border-accent bg-accent/10'
: uploading
? 'border-border/50 bg-elevated/30 pointer-events-none'
: 'border-border/40 hover:border-accent/50 hover:bg-accent/[0.03]'
}`}
>
{uploading ? (
<>
<Loader2 className="h-5 w-5 text-accent animate-spin" />
<span className="text-[11px] text-muted"></span>
</>
) : (
<>
<Upload className={`h-5 w-5 ${dragOver ? 'text-accent' : 'text-muted'}`} />
<span className={`text-[11px] ${dragOver ? 'text-accent' : 'text-secondary'}`}>
</span>
<span className="text-[10px] text-muted"> CSV / Excel symbol </span>
</>
)}
</div>
</>
) : ingestTab === 'api' ? (
<ExtDataApiPanel config={config} copied={copied} setCopied={setCopied} />
) : (
<ExtDataPullPanel config={config} onSaved={() => qc.invalidateQueries({ queryKey: QK.extData })} />
)}
{uploadResult && (
<div className="text-[11px] text-green-400 bg-green-500/10 rounded-lg px-3 py-2 text-center flex items-center justify-center gap-1.5 font-medium">
<CheckCircle2 className="h-3.5 w-3.5" /> · {uploadResult.rows} · {uploadResult.date}
</div>
)}
<button
onClick={() => setShowDelete(true)}
className="w-full text-center text-[10px] text-danger/60 hover:text-danger transition-colors"
>
</button>
</div>
</SettingsModal>
)}
</AnimatePresence>
{/* 删除二次确认弹窗 */}
<AnimatePresence>
{showDelete && (
<div className="fixed inset-0 z-[60] 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/60 backdrop-blur-sm"
onClick={() => !deleting && setShowDelete(false)}
/>
<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-[380px] 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-danger/12 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground mb-1.5">{config.label}</h3>
<p className="text-xs text-secondary leading-relaxed">
<span className="text-danger font-medium"></span>/
</p>
<p className="mt-2 text-[11px] text-danger/90">
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
onClick={() => setShowDelete(false)}
disabled={deleting}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors disabled:opacity-50"
>
</button>
<button
onClick={() => { onDelete(); setShowDelete(false) }}
disabled={deleting}
className="px-3 py-1.5 rounded-btn bg-danger/90 text-base text-sm font-medium hover:bg-danger disabled:opacity-50 transition-colors"
>
{deleting ? '删除中…' : '确认删除'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,252 @@
import { useState } from 'react'
import { CalendarDays, TrendingUp, FileText, Wallet, Activity, Sparkles } from 'lucide-react'
import {
useFinancialMetrics,
useFinancialIncome,
useFinancialBalanceSheet,
useFinancialCashFlow,
} from '@/lib/useFinancials'
import { fmtPrice, fmtBigNum, fmtDate } from '@/lib/format'
import { Skeleton } from '@/components/data/Skeleton'
interface Props {
symbol: string
name: string
}
type TabKey = 'metrics' | 'income' | 'balance_sheet' | 'cash_flow'
const TABS: { key: TabKey; label: string; icon: typeof TrendingUp }[] = [
{ key: 'metrics', label: '核心指标', icon: TrendingUp },
{ key: 'income', label: '利润表', icon: FileText },
{ key: 'balance_sheet', label: '资产负债表', icon: Wallet },
{ key: 'cash_flow', label: '现金流量表', icon: Activity },
]
// 字段定义:键 → (中文名, 格式化类型)
// pct=百分点(存的是 12.3 表示 12.3%); amount=金额(元,转亿/万亿); perShare=每股; num=普通数值(保留2位)
type FmtType = 'pct' | 'amount' | 'perShare' | 'num'
type FieldDef = { label: string; fmt: FmtType; group?: string }
const FIELD_DEFS: Record<TabKey, FieldDef[]> = {
metrics: [
{ label: '基本每股收益 EPS', fmt: 'perShare', key: 'eps_basic' } as any,
{ label: '稀释每股收益 EPS', fmt: 'perShare', key: 'eps_diluted' } as any,
{ label: '每股净资产 BPS', fmt: 'perShare', key: 'bps' } as any,
{ label: '每股经营现金流', fmt: 'perShare', key: 'ocfps' } as any,
{ label: '净资产收益率 ROE', fmt: 'pct', key: 'roe' } as any,
{ label: '稀释 ROE', fmt: 'pct', key: 'roe_diluted' } as any,
{ label: '总资产收益率 ROA', fmt: 'pct', key: 'roa' } as any,
{ label: '销售毛利率', fmt: 'pct', key: 'gross_margin' } as any,
{ label: '销售净利率', fmt: 'pct', key: 'net_margin' } as any,
{ label: '资产负债率', fmt: 'pct', key: 'debt_to_asset_ratio' } as any,
{ label: '营业收入同比增长', fmt: 'pct', key: 'revenue_yoy' } as any,
{ label: '净利润同比增长', fmt: 'pct', key: 'net_income_yoy' } as any,
{ label: '经营现金/营收', fmt: 'pct', key: 'operating_cash_to_revenue' } as any,
{ label: '存货周转率', fmt: 'num', key: 'inventory_turnover' } as any,
],
income: [
{ label: '营业收入', fmt: 'amount', key: 'revenue' } as any,
{ label: '营业成本', fmt: 'amount', key: 'operating_cost' } as any,
{ label: '营业利润', fmt: 'amount', key: 'operating_profit' } as any,
{ label: '销售费用', fmt: 'amount', key: 'selling_expense' } as any,
{ label: '管理费用', fmt: 'amount', key: 'admin_expense' } as any,
{ label: '研发费用', fmt: 'amount', key: 'rd_expense' } as any,
{ label: '财务费用', fmt: 'amount', key: 'financial_expense' } as any,
{ label: '营业外收入', fmt: 'amount', key: 'non_operating_income' } as any,
{ label: '营业外支出', fmt: 'amount', key: 'non_operating_expense' } as any,
{ label: '利润总额', fmt: 'amount', key: 'total_profit' } as any,
{ label: '所得税', fmt: 'amount', key: 'income_tax' } as any,
{ label: '净利润', fmt: 'amount', key: 'net_income' } as any,
{ label: '归母净利润', fmt: 'amount', key: 'net_income_attributable' } as any,
{ label: '扣非净利润', fmt: 'amount', key: 'net_income_deducted' } as any,
{ label: '基本每股收益', fmt: 'perShare', key: 'basic_eps' } as any,
{ label: '稀释每股收益', fmt: 'perShare', key: 'diluted_eps' } as any,
],
balance_sheet: [
{ label: '资产总计', fmt: 'amount', key: 'total_assets' } as any,
{ label: '流动资产合计', fmt: 'amount', key: 'total_current_assets' } as any,
{ label: '非流动资产合计', fmt: 'amount', key: 'total_non_current_assets' } as any,
{ label: '货币资金', fmt: 'amount', key: 'cash_and_equivalents' } as any,
{ label: '应收账款', fmt: 'amount', key: 'accounts_receivable' } as any,
{ label: '存货', fmt: 'amount', key: 'inventory' } as any,
{ label: '固定资产', fmt: 'amount', key: 'fixed_assets' } as any,
{ label: '无形资产', fmt: 'amount', key: 'intangible_assets' } as any,
{ label: '商誉', fmt: 'amount', key: 'goodwill' } as any,
{ label: '负债合计', fmt: 'amount', key: 'total_liabilities' } as any,
{ label: '流动负债合计', fmt: 'amount', key: 'total_current_liabilities' } as any,
{ label: '非流动负债合计', fmt: 'amount', key: 'total_non_current_liabilities' } as any,
{ label: '短期借款', fmt: 'amount', key: 'short_term_borrowing' } as any,
{ label: '长期借款', fmt: 'amount', key: 'long_term_borrowing' } as any,
{ label: '应付账款', fmt: 'amount', key: 'accounts_payable' } as any,
{ label: '所有者权益合计', fmt: 'amount', key: 'total_equity' } as any,
{ label: '归母所有者权益', fmt: 'amount', key: 'equity_attributable' } as any,
{ label: '未分配利润', fmt: 'amount', key: 'retained_earnings' } as any,
{ label: '少数股东权益', fmt: 'amount', key: 'minority_interest' } as any,
],
cash_flow: [
{ label: '经营活动现金流净额', fmt: 'amount', key: 'net_operating_cash_flow' } as any,
{ label: '投资活动现金流净额', fmt: 'amount', key: 'net_investing_cash_flow' } as any,
{ label: '筹资活动现金流净额', fmt: 'amount', key: 'net_financing_cash_flow' } as any,
{ label: '固定资产/无形资产投资', fmt: 'amount', key: 'capex' } as any,
{ label: '现金及等价物净增加额', fmt: 'amount', key: 'net_cash_change' } as any,
],
}
function formatValue(v: number | null | undefined, fmt: FmtType): string {
if (v == null || Number.isNaN(v)) return '—'
switch (fmt) {
case 'pct':
// 存储的是百分点(12.3 表示 12.3%),直接保留2位 + %
return `${v.toFixed(2)}%`
case 'amount':
// 金额(元)→ 亿/万亿;保留负号
return fmtBigNum(v)
case 'perShare':
return fmtPrice(v, 2)
case 'num':
default:
return v.toFixed(2)
}
}
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)
}
const metrics = useFinancialMetrics(symbol)
const income = useFinancialIncome(symbol)
const balance = useFinancialBalanceSheet(symbol)
const cashFlow = useFinancialCashFlow(symbol)
const queryMap = {
metrics: metrics,
income: income,
balance_sheet: balance,
cash_flow: cashFlow,
} as const
const current = queryMap[tab]
// 按 period_end 降序(最新在前);同步默认 latest_only,通常只有1期
const rows = (current.data?.data ?? []).slice().sort((a, b) =>
(b.period_end ?? '').localeCompare(a.period_end ?? '')
)
const fieldDefs = FIELD_DEFS[tab]
// 头部报告期信息取最新一期(优先用当前 tab,兜底用 metrics)
const latestPeriod = rows[0]?.period_end ?? metrics.data?.data?.[0]?.period_end ?? null
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="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">
<span className="text-lg font-semibold text-foreground">{name}</span>
<span className="text-xs font-mono text-muted">{symbol}</span>
</div>
<div className="flex items-center gap-2 ml-auto">
{latestPeriod && (
<div className="flex items-center gap-1.5 text-xs text-secondary">
<CalendarDays className="h-3.5 w-3.5" />
<span> <span className="font-mono">{latestPeriod}</span></span>
{latestAnnounce && (
<span className="text-muted">· {fmtDate(latestAnnounce)}</span>
)}
</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>
{/* 标签页 */}
<div className="flex items-center gap-1 px-3 pt-2 border-b border-border/60">
{TABS.map(t => {
const Icon = t.icon
const isActive = tab === t.key
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors ${
isActive
? 'border-accent text-accent'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
<Icon className="h-3.5 w-3.5" />
{t.label}
</button>
)
})}
</div>
{/* 表格内容 */}
<div className="p-4">
{current.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex justify-between">
<Skeleton w="w-32" h="h-4" />
<Skeleton w="w-20" h="h-4" />
</div>
))}
</div>
) : rows.length === 0 ? (
<div className="py-10 text-center text-xs text-muted">
{TABS.find(t => t.key === tab)?.label}
</div>
) : (
<div className="space-y-5">
{/* 多期时为每期渲染一组;单期时只有一组 */}
{rows.map((row, ri) => (
<div key={row.period_end ?? ri}>
{rows.length > 1 && (
<div className="text-[11px] text-muted mb-2 flex items-center gap-1.5">
<CalendarDays className="h-3 w-3" />
<span className="font-mono text-secondary">{row.period_end}</span>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-0">
{fieldDefs.map((def: any) => {
const val = row[def.key]
return (
<div
key={def.key}
className="flex items-baseline justify-between gap-3 py-2 border-b border-border/40"
>
<span className="text-xs text-secondary shrink-0">{def.label}</span>
<span className="text-sm font-mono tabular-nums text-foreground text-right">
{formatValue(val, def.fmt)}
</span>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</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>
)}
</div>
)
}
@@ -0,0 +1,128 @@
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Search, Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
interface Props {
onSelect: (symbol: string, name: string) => void
}
/**
* 个股模糊搜索框 —— 财务页主入口。
* 复用 instrumentSearch 后端(代码 / 名称模糊匹配),单选即跳转该股财务详情。
* 模式对齐 Watchlist.StockSearchBox:useQuery + 外部点击关闭 + 键盘导航。
*/
export function StockFinancialSearch({ onSelect }: Props) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const search = useQuery({
queryKey: QK.instrumentSearch(query),
queryFn: () => api.instrumentSearch(query),
enabled: query.trim().length > 0,
staleTime: 30_000,
})
const results = search.data?.results ?? []
// 外部点击关闭下拉
useEffect(() => {
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
function handleSelect(r: { symbol: string; name: string }) {
onSelect(r.symbol, r.name)
setQuery('')
setOpen(false)
setActiveIdx(-1)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') { setOpen(false); return }
if (!open || results.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIdx(i => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIdx(i => Math.max(i - 1, -1))
} else if (e.key === 'Enter') {
e.preventDefault()
if (activeIdx >= 0) handleSelect(results[activeIdx])
else if (results.length > 0) handleSelect(results[0])
}
}
const trimmed = query.trim()
return (
<div ref={containerRef} className="relative w-full max-w-xl mx-auto">
<div className="relative flex items-center">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted pointer-events-none" />
<input
ref={inputRef}
type="text"
placeholder="输入股票代码或名称,如 600000 / 浦发"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); setActiveIdx(-1) }}
onFocus={() => { if (trimmed) setOpen(true) }}
onKeyDown={handleKeyDown}
// 较宽、更醒目 —— 作为财务页主入口
className="w-full h-11 pl-11 pr-10 rounded-card bg-surface border border-border text-sm text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 focus:bg-base transition-colors"
/>
{search.isFetching && (
<Loader2 className="absolute right-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted animate-spin" />
)}
</div>
<AnimatePresence>
{open && trimmed && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
className="absolute left-0 right-0 top-full mt-1.5 z-50 max-h-[360px] overflow-y-auto rounded-card border border-border bg-base shadow-xl"
>
{search.isLoading ? (
<div className="px-4 py-6 flex items-center justify-center gap-2 text-xs text-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : results.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-muted">
</div>
) : (
results.map((r, i) => (
<button
key={r.symbol}
type="button"
onClick={() => handleSelect(r)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors duration-100 ${
i === activeIdx ? 'bg-accent/10 text-accent' : 'hover:bg-elevated text-foreground'
}`}
>
<span className="font-mono shrink-0 text-xs w-[88px]">{r.symbol}</span>
<span className="truncate text-sm flex-1">{r.name}</span>
{r.code && <span className="text-[10px] text-muted font-mono shrink-0">{r.code}</span>}
</button>
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,365 @@
import { useState } from 'react'
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'
interface Props {
/** 编辑现有规则;null=新建 */
rule: MonitorRule | null
/** 新建时的预填值 (如个股弹窗传入 symbol/scope) */
preset?: Partial<MonitorRule>
/** 极简模式: 个股场景, 隐藏 type/scope/阈值等, 只显示信号点选 */
simple?: boolean
onClose: () => void
onSaved?: () => void
}
const TYPE_DEFAULT_NAME: Record<string, string> = {
signal: '个股信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控',
}
const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
id: genRuleId(),
name: '',
enabled: true,
type: 'signal',
scope: 'symbols',
symbols: [],
sector: null,
strategy_id: null,
direction: 'entry',
conditions: [],
logic: 'or',
cooldown_seconds: 3600,
severity: 'info',
message: '',
...preset,
})
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 [editing] = useState(!!rule)
const [draft, setDraft] = useState<MonitorRule>(
rule ? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) } : emptyRule(preset),
)
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
const symbolSearch = useQuery({
queryKey: QK.instrumentSearch(symbolQuery),
queryFn: () => api.instrumentSearch(symbolQuery, 20),
enabled: symbolQuery.length > 0,
})
const save = useMutation({
mutationFn: () => {
const d = { ...draft }
// name 为空时用默认名
if (!d.name.trim()) {
const base = TYPE_DEFAULT_NAME[d.type] ?? '监控规则'
d.name = d.scope === 'symbols' && d.symbols.length > 0
? `${base} · ${d.symbols[0]}${d.symbols.length > 1 ? `${d.symbols.length}` : ''}`
: base
}
if (d.type === 'strategy') {
if (!d.strategy_id) throw new Error('策略监控必须选择一个策略')
} else {
if (d.conditions.length === 0) throw new Error('至少选择一个触发条件')
for (const c of d.conditions) {
if (!c.field || !c.op) throw new Error('条件填写不完整')
if (c.op !== 'truth' && (c.value === null || c.value === undefined)) throw new Error('阈值条件需要数值')
}
}
if (d.scope === 'symbols' && d.symbols.length === 0) throw new Error('请选择至少一只股票')
return api.monitorRuleSave(d)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.monitorRules })
onSaved?.()
onClose()
},
onError: err => setError(String((err as any)?.message ?? err)),
})
// 条件编辑
const updateCond = (idx: number, patch: Partial<MonitorCondition>) =>
setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
const addCond = (op: 'truth' | 'threshold') =>
setDraft(d => ({
...d,
conditions: [...d.conditions, op === 'truth'
? { field: 'signal_volume_surge', op: 'truth' }
: { field: 'rsi_14', op: '<', value: 30 }],
}))
const removeCond = (idx: number) =>
setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
const addSymbol = (sym: string) => {
if (!draft.symbols.includes(sym)) {
setDraft(d => ({ ...d, symbols: [...d.symbols, sym] }))
}
setSymbolQuery('')
}
const thresholdFields = options.data?.threshold_fields ?? []
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
const selectedSignals = draft.conditions.filter(c => c.op === 'truth').map(c => c.field)
const thresholdConds = draft.conditions.filter(c => c.op !== 'truth')
const onSignalPickerChange = (next: string[]) => {
const nonTruthConds = draft.conditions.filter(c => c.op !== 'truth')
const truthConds: MonitorCondition[] = next.map(field => ({ field, op: 'truth' }))
setDraft(d => ({ ...d, conditions: [...nonTruthConds, ...truthConds] }))
}
// ── 极简模式: 只显示信号点选 + 可选描述 ──
if (simple) {
return (
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium text-foreground">{editing ? '编辑监控' : '加入监控'}</h3>
<button onClick={onClose} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground cursor-pointer">
<X className="h-4 w-4" />
</button>
</div>
{draft.symbols.length > 0 && (
<div className="flex flex-wrap gap-1">
{draft.symbols.map(s => (
<span key={s} className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-secondary font-mono">{s}</span>
))}
</div>
)}
<div>
<div className="mb-1.5 text-[11px] text-muted"> ()</div>
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
</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" />
</label>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
// ── 完整模式: 监控页新建/编辑 ──
return (
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground">{editing ? '编辑监控规则' : '新建监控规则'}</h3>
<p className="mt-1 text-[11px] text-muted">,</p>
</div>
<button onClick={onClose} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground cursor-pointer">
<X className="h-4 w-4" />
</button>
</div>
{/* 描述 (可选) + 类型 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="md:col-span-2 space-y-1.5">
<span className="text-[11px] text-muted"> ()</span>
<input value={draft.name} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="留空用默认名称" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={draft.type} onChange={e => setDraft(d => ({ ...d, type: e.target.value as MonitorRule['type'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.types ?? []).map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</label>
</div>
{/* 作用范围 */}
<div className="space-y-2">
<span className="text-[11px] text-muted"></span>
<div className="flex items-center gap-2">
<select value={draft.scope} onChange={e => setDraft(d => ({ ...d, scope: e.target.value as MonitorRule['scope'] }))} className="h-9 w-32 rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.scopes ?? []).map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
{draft.scope === 'symbols' && (
<div className="flex-1 flex flex-wrap items-center gap-1.5">
{draft.symbols.map(sym => (
<span key={sym} className="inline-flex items-center gap-1 rounded bg-elevated px-1.5 py-0.5 text-[10px] text-secondary">
{sym}
<button onClick={() => setDraft(d => ({ ...d, symbols: d.symbols.filter(s => s !== sym) }))} className="text-muted hover:text-danger cursor-pointer">
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
<div className="relative">
<input
value={symbolQuery}
onChange={e => setSymbolQuery(e.target.value)}
placeholder="搜索股票..."
className="h-7 w-32 rounded border border-border bg-base pl-6 pr-2 text-[11px] text-foreground focus:outline-none focus:border-accent/50"
/>
<Search className="absolute left-1.5 top-1.5 h-3.5 w-3.5 text-muted" />
{symbolSearch.data && symbolSearch.data.results.length > 0 && (
<div className="absolute z-10 mt-1 max-h-48 w-48 overflow-auto rounded border border-border bg-surface shadow-lg">
{symbolSearch.data.results.map(r => (
<button key={r.symbol} onClick={() => addSymbol(r.symbol)} className="block w-full px-2 py-1 text-left text-[11px] hover:bg-elevated cursor-pointer">
<span className="font-mono text-foreground/80">{r.symbol}</span>
<span className="ml-1 text-muted">{r.name}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
{draft.scope === 'all' && <span className="text-[11px] text-muted"></span>}
{draft.scope === 'sector' && <span className="text-[11px] text-muted/60">(,)</span>}
</div>
</div>
{/* 触发条件 (非 strategy) */}
{draft.type !== 'strategy' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"></span>
<div className="flex items-center gap-2">
<select value={draft.logic} onChange={e => setDraft(d => ({ ...d, logic: e.target.value as MonitorRule['logic'] }))} className="h-7 rounded border border-border bg-base px-1.5 text-[11px] text-foreground">
{(options.data?.logics ?? []).map(l => <option key={l.key} value={l.key}>{l.label}</option>)}
</select>
<button onClick={() => addCond('truth')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
<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>
</div>
{selectedSignals.length > 0 || (options.data?.builtin_signals ?? []).length > 0 ? (
<div>
<div className="mb-1.5 text-[10px] text-muted/70"> ()</div>
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
</div>
) : null}
{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="w-32 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 w-3" />
</button>
</div>
)
})}
</div>
)}
{draft.conditions.length === 0 && (
<div className="rounded border border-dashed border-border px-3 py-4 text-center text-[11px] text-muted">
</div>
)}
</div>
)}
{/* strategy 类型: 选策略 + 方向 */}
{draft.type === 'strategy' && (
<div className="space-y-2">
<span className="text-[11px] text-muted"></span>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<label className="md:col-span-2 space-y-1.5">
<span className="text-[10px] text-muted/70"></span>
<select
value={draft.strategy_id ?? ''}
onChange={e => setDraft(d => ({ ...d, strategy_id: e.target.value || null }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
<option value=""> </option>
{(strategies.data?.presets ?? []).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<label className="space-y-1.5">
<span className="text-[10px] text-muted/70"></span>
<select
value={draft.direction}
onChange={e => setDraft(d => ({ ...d, direction: e.target.value as MonitorRule['direction'] }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{(options.data?.directions ?? []).map(d => <option key={d.key} value={d.key}>{d.label}</option>)}
</select>
</label>
</div>
<p className="text-[10px] leading-4 text-muted/70">
entry=,exit=,both=
</p>
</div>
)}
{/* 通知设置 */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">()</span>
<input type="number" value={draft.cooldown_seconds} onChange={e => setDraft(d => ({ ...d, cooldown_seconds: parseInt(e.target.value) || 0 }))} min={0} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={draft.severity} onChange={e => setDraft(d => ({ ...d, severity: e.target.value as MonitorRule['severity'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.severities ?? []).map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
</label>
<label className="space-y-1.5 md:col-span-1">
<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" />
</label>
</div>
{/* Webhook 推送 (占位, 后续开发) */}
<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">
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[10px] text-muted"></span>
</label>
</div>
<p className="text-[10px] leading-relaxed text-muted">
( QMT),
</p>
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
@@ -0,0 +1,162 @@
import { X } from 'lucide-react'
// ===== 筛选类型 =====
export interface ScreenerFilter {
priceMin: string
priceMax: string
changePctMin: string
changePctMax: string
momentum5dMin: string
momentum5dMax: string
amountMin: string // 成交额最小(亿)
marketCapMin: string // 市值最小(亿)
marketCapMax: string // 市值最大(亿)
floatCapMin: string // 流通市值最小(亿)
floatCapMax: string // 流通市值最大(亿)
volRatioMin: string // 量比最小
rsiMin: string
rsiMax: string
}
export const defaultFilter: ScreenerFilter = {
priceMin: '', priceMax: '',
changePctMin: '', changePctMax: '',
momentum5dMin: '', momentum5dMax: '',
amountMin: '',
marketCapMin: '', marketCapMax: '',
floatCapMin: '', floatCapMax: '',
volRatioMin: '',
rsiMin: '', rsiMax: '',
}
export function filterActive(f: ScreenerFilter): boolean {
return Object.values(f).some(v => v !== '')
}
export function countActiveFilters(f: ScreenerFilter): number {
let n = 0
if (f.priceMin || f.priceMax) n++
if (f.changePctMin || f.changePctMax) n++
if (f.momentum5dMin || f.momentum5dMax) n++
if (f.amountMin) n++
if (f.marketCapMin || f.marketCapMax) n++
if (f.floatCapMin || f.floatCapMax) n++
if (f.volRatioMin) n++
if (f.rsiMin || f.rsiMax) n++
return n
}
export function applyFilter(rows: any[], f: ScreenerFilter): any[] {
if (!filterActive(f)) return rows
const num = (v: string) => v === '' ? null : Number(v)
return rows.filter((r) => {
const close = Number(r.close ?? 0)
const v = (field: string) => num(field)
// 现价
if (v(f.priceMin) != null && close < v(f.priceMin)!) return false
if (v(f.priceMax) != null && close > v(f.priceMax)!) return false
// 涨跌幅(%)
const chg = (r.change_pct ?? 0) * 100
if (v(f.changePctMin) != null && chg < v(f.changePctMin)!) return false
if (v(f.changePctMax) != null && chg > v(f.changePctMax)!) return false
// 5日涨幅(%)
const m5 = (r.momentum_5d ?? 0) * 100
if (v(f.momentum5dMin) != null && m5 < v(f.momentum5dMin)!) return false
if (v(f.momentum5dMax) != null && m5 > v(f.momentum5dMax)!) return false
// 成交额(亿)
const amount = (r.amount ?? 0) / 1e8
if (v(f.amountMin) != null && amount < v(f.amountMin)!) return false
// 市值(亿)
const cap = close * (r.total_shares ?? 0) / 1e8
if (v(f.marketCapMin) != null && cap < v(f.marketCapMin)!) return false
if (v(f.marketCapMax) != null && cap > v(f.marketCapMax)!) return false
// 流通市值(亿)
const fcap = close * (r.float_shares ?? 0) / 1e8
if (v(f.floatCapMin) != null && fcap < v(f.floatCapMin)!) return false
if (v(f.floatCapMax) != null && fcap > v(f.floatCapMax)!) return false
// 量比
if (v(f.volRatioMin) != null && (r.vol_ratio_5d ?? 0) < v(f.volRatioMin)!) return false
// RSI
const rsi = r.rsi_14 ?? 0
if (v(f.rsiMin) != null && rsi < v(f.rsiMin)!) return false
if (v(f.rsiMax) != null && rsi > v(f.rsiMax)!) return false
return true
})
}
// ===== 筛选面板 =====
export function FilterPanel({ value, onChange, onClose, onReset }: {
value: ScreenerFilter
onChange: (f: ScreenerFilter) => void
onClose: () => void
onReset: () => void
}) {
const set = (key: keyof ScreenerFilter, v: string) => onChange({ ...value, [key]: v })
const fields: { label: string; min: keyof ScreenerFilter; max: keyof ScreenerFilter; unit: string; step?: string }[] = [
{ label: '现价', min: 'priceMin', max: 'priceMax', unit: '元', step: '0.1' },
{ label: '涨跌幅', min: 'changePctMin', max: 'changePctMax', unit: '%' },
{ label: '5日涨幅', min: 'momentum5dMin', max: 'momentum5dMax', unit: '%' },
{ label: '成交额', min: 'amountMin', max: 'amountMin', unit: '亿', step: '0.5' },
{ label: '总市值', min: 'marketCapMin', max: 'marketCapMax', unit: '亿', step: '10' },
{ label: '流通市值', min: 'floatCapMin', max: 'floatCapMax', unit: '亿', step: '10' },
{ label: '量比', min: 'volRatioMin', max: 'volRatioMin', unit: '', step: '0.1' },
{ label: 'RSI14', min: 'rsiMin', max: 'rsiMax', unit: '', step: '1' },
]
return (
<div className="rounded-card border border-accent/30 bg-accent/[0.03] p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-accent"></span>
<button onClick={onClose} className="p-0.5 rounded text-secondary hover:text-foreground transition-colors">
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2.5">
{fields.map((f) => {
const isRange = f.min !== f.max
return (
<div key={f.label} className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary shrink-0 w-14 text-right">{f.label}</span>
<input
type="number"
placeholder="最小"
value={value[f.min]}
onChange={(e) => set(f.min, e.target.value)}
step={f.step}
className="w-16 px-1.5 py-1 rounded-btn bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
{isRange && (
<>
<span className="text-[10px] text-muted">~</span>
<input
type="number"
placeholder="最大"
value={value[f.max]}
onChange={(e) => set(f.max, e.target.value)}
step={f.step}
className="w-16 px-1.5 py-1 rounded-btn bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
</>
)}
{f.unit && <span className="text-[10px] text-muted shrink-0">{f.unit}</span>}
</div>
)
})}
</div>
{filterActive(value) && (
<div className="flex items-center gap-2 pt-1">
<button
onClick={onReset}
className="text-[11px] text-muted hover:text-danger transition-colors"
>
</button>
<span className="text-[10px] text-muted"> · </span>
</div>
)}
</div>
)
}
@@ -0,0 +1,320 @@
/**
* 策略结果表格。
*
* 表格骨架(表头排序/sticky/遍历)由共享的 StockDataTable 承担;本组件只负责
* 策略页特有的单元格内容:symbol 列(含加自选按钮 + 失效行灰显)、strategies、
* score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。
*/
import { useState, type CSSProperties, type ReactNode } from 'react'
import { Check, Plus, Eye, EyeOff } from 'lucide-react'
import type { KlineRow } from '@/lib/api'
import { fmtPrice } from '@/lib/format'
import type { ColumnConfig } from '@/lib/screener-columns'
import { getSignals, signalCls } from '@/lib/stock-table'
import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives'
import { resolveCandleConfig } from '@/lib/list-columns'
import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { StockDataTable, type SortState } from '@/components/stock-table/StockDataTable'
interface ScreenerTableProps {
rows: any[]
columns: ColumnConfig[]
strategyIdToName: Record<string, string>
symbolStrategyMap: Map<string, string[]>
activeStrategy: string | null
watchlistSet: Set<string>
onPreview: (symbol: string, name: string) => void
onToggleWatchlist: (symbol: string, inList: boolean) => void
watchlistPending: boolean
/** symbol → 日k 数据,仅当启用日k列时传入 */
klineData?: Record<string, KlineRow[]>
/** 日k蜡烛图是否显示(表头眼睛开关) */
dailyKChartVisible?: boolean
onToggleDailyKChart?: () => void
/** 表头排序(受控,由 Screener.tsx 传入) */
sort?: SortState | null
onSortToggle?: (colId: string) => void
}
/** 渲染标签数组(含 maxTags 折叠/展开、横竖排列)。策略列与 ext 列共用。 */
function renderTagList(
tags: string[],
col: ColumnConfig,
expanded: boolean,
onToggle: () => void,
tagClassName: string,
): ReactNode {
if (tags.length === 0) return <span className="text-muted"></span>
const cfg = col.extDisplay
const maxTags = cfg?.maxTags ?? 0
const showAll = maxTags <= 0 || expanded || tags.length <= maxTags
const sliced = showAll ? tags : tags.slice(0, maxTags)
const hiddenIndices = maxTags > 0 ? cfg?.hiddenIndices : undefined
const visibleTags = hiddenIndices?.length
? sliced.filter((_, i) => !hiddenIndices.includes(i))
: sliced
const hiddenCount = tags.length - visibleTags.length
const isVertical = cfg?.tagLayout === 'vertical' && !expanded
return (
<div className={isVertical ? 'flex flex-col items-start gap-0.5' : 'flex flex-wrap gap-0.5'}>
{visibleTags.map((tag, i) => (
<span key={i} className={tagClassName}>{tag}</span>
))}
{!showAll && hiddenCount > 0 && (
<button
onClick={onToggle}
className="inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-accent bg-accent/10 hover:bg-accent/20 transition-colors"
>
+{hiddenCount}
</button>
)}
{showAll && maxTags > 0 && tags.length > maxTags && (
<button
onClick={onToggle}
className="inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-muted hover:text-foreground transition-colors"
>
</button>
)}
</div>
)
}
const EXT_TAG_CLS = 'inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-yellow-500 bg-yellow-500/10'
const STRATEGY_TAG_CLS = 'inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight bg-amber-500/10 text-amber-600 border border-amber-500/20'
function renderExtValue(
val: any,
col: ColumnConfig,
expanded: boolean,
onToggle: () => void,
): ReactNode {
if (val == null || Number.isNaN(val)) return <span className="text-muted"></span>
if (typeof val === 'number') {
const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val)
return <span className="tabular-nums">{displayVal}</span>
}
if (typeof val === 'boolean') {
return <span className={val ? 'text-bull' : 'text-muted'}>{val ? '是' : '否'}</span>
}
const cfg = col.extDisplay
const str = String(val)
if (cfg?.displayMode === 'text') return <span className="text-foreground">{str}</span>
const separator = cfg?.separator?.trim() || null
const tags = separator
? str.split(separator).map(s => s.trim()).filter(Boolean)
: str.split(/[、,;\-]/).map(s => s.trim()).filter(Boolean)
return renderTagList(tags, col, expanded, onToggle, EXT_TAG_CLS)
}
export function ScreenerTable({
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {},
dailyKChartVisible = true, onToggleDailyKChart,
sort, onSortToggle,
}: ScreenerTableProps) {
const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set())
// 日k列渲染尺寸(按眼睛开关取开启/收起尺寸)
const candleCol = columns.find(c => c.source.type === 'builtin' && c.source.key === 'candle' && c.visible)
const candleResolved = resolveCandleConfig(candleCol?.candleConfig)
const candleSize = dailyKChartVisible
? { width: candleResolved.enabledWidth, height: candleResolved.enabledHeight }
: { width: candleResolved.disabledWidth, height: candleResolved.disabledHeight }
const toggleExpand = (key: string) => {
setExpandedCells(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
const renderCell = (r: any, col: ColumnConfig): ReactNode => {
// ext 列
if (col.source.type === 'ext') {
const { configId, fieldName } = col.source
const val = r[`${configId}__${fieldName}`]
const cellKey = `${r.symbol}::${col.id}`
const expanded = expandedCells.has(cellKey)
const tdClass = val == null || Number.isNaN(val)
? 'px-3 py-2 text-center text-muted'
: typeof val === 'number'
? 'px-3 py-2 text-right num tabular-nums'
: 'px-3 py-2 text-center'
const style: CSSProperties = {}
if (col.extDisplay?.maxWidth) style.maxWidth = col.extDisplay.maxWidth
return (
<td key={col.id} className={tdClass} style={style}>
{renderExtValue(val, col, expanded, () => toggleExpand(cellKey))}
</td>
)
}
const isExpired = !!r._expired
const key = col.source.key
// 策略页特有 / 需上下文的列
switch (key) {
case 'symbol': {
const board = boardTag(r.symbol)
const inWatchlist = watchlistSet.has(r.symbol)
return (
<td key={col.id} className="px-4 py-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onPreview(r.symbol, r.name ?? '')}
className={`flex items-center gap-2 text-left ${isExpired ? 'cursor-default' : ''}`}
>
{board ? (
<span className={`shrink-0 inline-flex items-center justify-center w-[18px] h-[18px] rounded text-[9px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
) : (
<span className="shrink-0 w-[18px]" />
)}
<span className="font-mono text-secondary group-hover:text-accent transition-colors duration-150 leading-snug">
{r.symbol}
</span>
{r.name && (
<span className="text-[11px] text-muted truncate group-hover:text-secondary transition-colors duration-150 leading-snug">
{r.name}
</span>
)}
</button>
{isExpired ? (
<span className="shrink-0 inline-flex items-center px-1.5 py-px rounded text-[9px] font-medium leading-tight bg-red-500/10 text-red-400/60 border border-red-500/15">
</span>
) : (
<button
type="button"
onClick={() => onToggleWatchlist(r.symbol, inWatchlist)}
disabled={watchlistPending}
className={`shrink-0 inline-flex items-center justify-center w-5 h-5 rounded-full border transition-colors cursor-pointer
disabled:opacity-50
${inWatchlist
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border text-muted hover:border-accent/40 hover:text-accent'
}`}
title={inWatchlist ? '移出自选' : '加入自选'}
>
{inWatchlist ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</button>
)}
</div>
</td>
)
}
case 'strategies': {
const strats = symbolStrategyMap.get(r.symbol) ?? (activeStrategy ? [activeStrategy] : [])
const tags = strats.map(sid => strategyIdToName[sid] ?? sid)
const cellKey = `${r.symbol}::${col.id}`
const expanded = expandedCells.has(cellKey)
return (
<td key={col.id} className="px-3 py-2">
{renderTagList(tags, col, expanded, () => toggleExpand(cellKey), STRATEGY_TAG_CLS)}
</td>
)
}
case 'score': {
const numCls = 'px-3 py-2 text-right num tabular-nums'
return (
<td key={col.id} className={numCls}>
{r.score != null ? (
<span className={r.score >= 70 ? 'text-accent font-medium' : r.score >= 50 ? 'text-amber-400' : 'text-secondary'}>
{Number(r.score).toFixed(1)}
</span>
) : (
<span className="text-muted"></span>
)}
</td>
)
}
case 'signals': {
const signals = getSignals(r)
return (
<td key={col.id} className="px-3 py-2">
{signals.length > 0 ? (
<div className="flex flex-wrap gap-0.5">
{signals.slice(0, 3).map((s) => (
<span key={s.label} className={`inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight ${signalCls(s.type)}`}>
{s.label}
</span>
))}
{signals.length > 3 && (
<span className="text-[10px] text-muted">+{signals.length - 3}</span>
)}
</div>
) : (
<span className="text-muted text-xs"></span>
)}
</td>
)
}
case 'candle': {
const candleRows = klineData[r.symbol] ?? []
// 锁定列宽与行高:minWidth=maxWidth 防止 kline 加载前后整列宽度跳动(闪烁)
return (
<td
key={col.id}
className="px-3 py-2"
style={{ width: candleSize.width, minWidth: candleSize.width, maxWidth: candleSize.width, height: candleSize.height }}
>
<MiniCandlestick rows={candleRows} width={candleSize.width} height={candleSize.height} />
</td>
)
}
default:
// 纯数据列 → 共享原语
return renderBuiltinDataCell(r, col)
}
}
return (
<StockDataTable
columns={columns}
rows={rows}
renderCell={renderCell}
sort={sort}
onSortToggle={onSortToggle}
minWidth={Math.max(900, columns.filter(c => c.visible).length * 110)}
rowKey={(r: any) => `${r.symbol}${r._expired ? '-expired' : ''}`}
rowClassName={(r: any) => r._expired
? 'border-border/50 opacity-40'
: 'border-border hover:bg-elevated/50'
}
// 日k列表头:标签 + 显示/隐藏蜡烛图眼睛按钮(与自选页一致)
renderHeaderContent={onToggleDailyKChart ? (col) => {
if (col.source.type === 'builtin' && col.source.key === 'candle') {
return (
<span className="inline-flex items-center justify-center gap-1.5">
<span>{col.label}</span>
<button
type="button"
onClick={(event) => { event.stopPropagation(); onToggleDailyKChart() }}
className={`inline-flex items-center justify-center w-5 h-5 rounded transition-colors ${
dailyKChartVisible
? 'text-accent bg-accent/10 hover:bg-accent/20'
: 'text-muted hover:text-foreground hover:bg-elevated'
}`}
title={dailyKChartVisible ? '隐藏日k蜡烛' : '显示日k蜡烛'}
aria-label={dailyKChartVisible ? '隐藏日k蜡烛' : '显示日k蜡烛'}
>
{dailyKChartVisible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
</span>
)
}
return undefined
} : undefined}
/>
)
}
@@ -0,0 +1,86 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { SIGNAL_OPTIONS, cnSignal } from '@/lib/signals'
interface Props {
/** 当前选中的信号 ID 列表 */
signals: string[]
/** 选中变化回调 */
onChange: (next: string[]) => void
/** 买点 / 卖点 — 决定自定义信号的过滤与配色主题 */
kind: 'entry' | 'exit'
/** 渲染尺寸: dialog = 选股弹窗紧凑样式; panel = 回测页设置抽屉样式 */
variant?: 'dialog' | 'panel'
}
/**
* 买卖触发器信号选择 — 选股页弹窗 / 回测页共用。
*
* - 内置信号 (signal_*): 全部展示
* - 自定义信号 (csg_*): 按 kind 过滤 (entry / exit / both)
* - entry 蓝色主题, exit 橙色主题
*/
export function SignalPicker({ signals, onChange, kind, variant = 'panel' }: Props) {
const customSignalsQuery = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
const customOptions = useMemo(() => {
const list = (customSignalsQuery.data?.signals ?? [])
.filter(s => s.enabled && (s.kind === kind || s.kind === 'both'))
const names: Record<string, string> = {}
for (const cs of list) names[`csg_${cs.id}`] = cs.name
return { list, names }
}, [customSignalsQuery.data, kind])
const toggle = (sig: string) => {
const next = signals.includes(sig) ? signals.filter(x => x !== sig) : [...signals, sig]
onChange(next)
}
// 配色: entry 蓝色 (accent), exit 橙色 (warning/amber)
const isEntry = kind === 'entry'
const active = isEntry
? 'border-accent/50 bg-accent/10 text-accent'
: 'border-warning/50 bg-warning/10 text-warning'
const idle = variant === 'dialog'
? 'border-border bg-base text-muted hover:border-accent/40'
: 'border-border bg-base text-muted hover:border-accent/40'
const customActive = isEntry
? 'border-accent/50 bg-accent/10 text-accent'
: 'border-warning/50 bg-warning/10 text-warning'
const customIdle = 'border-amber-400/30 bg-amber-400/5 text-secondary hover:border-amber-400/50 hover:text-amber-400'
const btnCls = variant === 'dialog'
? 'rounded px-1.5 py-0.5 text-[10px] font-medium border transition-colors cursor-pointer'
: 'rounded-btn border px-2.5 py-1.5 text-[11px] transition-colors cursor-pointer'
return (
<div className="flex flex-wrap gap-1.5">
{SIGNAL_OPTIONS.map(sig => (
<button
key={sig}
type="button"
onClick={() => toggle(sig)}
className={`${btnCls} ${signals.includes(sig) ? active : idle}`}
>
{cnSignal(sig)}
</button>
))}
{customOptions.list.map(cs => {
const id = `csg_${cs.id}`
return (
<button
key={id}
type="button"
onClick={() => toggle(id)}
title="自定义信号"
className={`${btnCls} ${signals.includes(id) ? customActive : customIdle}`}
>
{customOptions.names[id]}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,513 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Sparkles, Save, Loader2, ChevronLeft, ChevronRight, AlertTriangle, Settings2, FileText, Copy, Check, Terminal } from 'lucide-react'
import { api } from '@/lib/api'
import { storage } from '@/lib/storage'
import { cn } from '@/lib/cn'
// ===== 工具函数 =====
function parsePyValue(v: string): any {
const s = v.trim()
if (s === 'True') return true
if (s === 'False') return false
if (s === 'None') return null
return JSON.parse(s)
}
function slugId(): string {
return 'ai_' + Date.now().toString(36)
}
function parseParams(code: string): any[] {
const m = code.match(/"params"\s*:\s*\[([^\]]*)\]/)
if (!m) return []
const inner = m[1]
const blocks = inner.match(/\{[^}]+\}/g) ?? []
return blocks.map(b => {
const get = (key: string) => {
const re = new RegExp('"' + key + '"\\s*:\\s*(.+?)\\s*[,}]')
const r = b.match(re)
return r ? r[1].trim().replace(/^"(.*)"$/, '$1') : ''
}
const id = get('id'); if (!id) return null
const type = get('type')
return { id, type, label: get('label'), default: parsePyValue(get('default') || 'null'), min: parsePyValue(get('min') || 'null'), max: parsePyValue(get('max') || 'null'), step: parsePyValue(get('step') || 'null') }
}).filter(Boolean)
}
function parseStringList(code: string, key: string): string[] {
const re = new RegExp(key + '\\s*=\\s*\\[([^\\]]+)\\]')
const m = code.match(re)
if (!m) return []
const items = m[1].match(/"([^"]+)"/g)
return items ? items.map(x => x.replace(/"/g, '')) : []
}
function parseScoring(code: string): Record<string, number> {
const m = code.match(/"scoring"\s*:\s*\{([^}]+)\}/)
if (!m) return {}
const items = m[1].match(/"([^"]+)"\s*:\s*([0-9.]+)/g)
if (!items) return {}
const result: Record<string, number> = {}
for (const item of items) {
const p = item.match(/"([^"]+)"\s*:\s*([0-9.]+)/)
if (p) result[p[1]] = parseFloat(p[2])
}
return result
}
function parseRules(code: string): string {
const m = code.match(/RULES\s*=\s*"""\s*([\s\S]*?)\s*"""/)
return m ? m[1].trim() : ''
}
function parseMetaField(code: string, field: string): string {
const m = code.match(new RegExp('"' + field + '"\\s*:\\s*"([^"]+)"'))
return m ? m[1] : ''
}
// ===== 常量 =====
const DIRECTIONS = [
{ value: 'long', label: '做多' },
{ value: 'short', label: '做空' },
{ value: 'monitor', label: '监控' },
]
// ===== 组件 =====
const CUSTOM_TEMPLATE = `"""策略简短描述"""
import polars as pl
META = {
"id": "custom_my_strategy",
"name": "我的策略",
"description": "策略描述",
"tags": ["自定义"],
"basic_filter": {
"price_min": 3, "price_max": 200,
"market_cap_min": 10e8, "amount_min": 0.5e8,
"exclude_st": True, "exclude_new_days": 30,
},
"params": [],
"scoring": {
"change_pct": 0.5, "vol_ratio_5d": 0.5,
},
"order_by": "score",
"descending": True,
"limit": 100,
}
ENTRY_SIGNALS = ["signal_n_day_high"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1. 规则一
2. 规则二
3. 规则三
"""
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
return (
(pl.col("close") > pl.col("ma20"))
& (pl.col("volume") > pl.col("vol_ma5") * 1.5)
)
`
interface Props { open: boolean; onClose: () => void; onSavedId?: (id: string) => void | Promise<void>; mode?: 'create' | 'modify' }
export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create' }: Props) {
// 根据 mode 选择存储 key
const draftStore = mode === 'modify' ? storage.strategyModify : storage.strategyDraft
const [step, setStep] = useState(1)
const [tab, setTab] = useState<'ai' | 'custom'>('ai')
const [customCopied, setCustomCopied] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [direction, setDirection] = useState('long')
const [rules, setRules] = useState('')
const [code, setCode] = useState('')
const [instruction, setInstruction] = useState('')
const [previewTab, setPreviewTab] = useState<'params' | 'code'>('params')
const [strategyId, setStrategyId] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [aiStatus, setAiStatus] = useState<{ configured: boolean } | null>(null)
const [checkedAi, setCheckedAi] = useState(false)
const [loaded, setLoaded] = useState(false)
// 打开时恢复草稿
useEffect(() => {
if (!open) { setLoaded(false); return }
const d = draftStore.get(null)
if (d) {
setStep(d.step ?? 1); setName(d.name ?? ''); setDescription(d.description ?? '')
setDirection(d.direction ?? 'long')
setRules(d.rules ?? ''); setCode(d.code ?? ''); setStrategyId(d.strategyId ?? '')
}
setLoaded(true)
}, [open])
// 打开时检查 AI 状态
useEffect(() => {
if (!open || checkedAi) return
api.strategyAiStatus().then(s => { setAiStatus(s); setCheckedAi(true) }).catch(() => setAiStatus({ configured: false }))
}, [open, checkedAi])
// 持久化
const persist = useCallback(() => {
if (!name && !rules && !code) {
draftStore.set(null)
} else {
draftStore.set({ name, description, direction, rules, code, step, strategyId })
}
}, [name, description, direction, rules, code, step, strategyId])
useEffect(() => { if (loaded) persist() }, [loaded, persist])
const clearDraft = () => {
setName(''); setDescription(''); setDirection('long')
setRules(''); setCode(''); setStep(1); setError(''); setInstruction('')
setStrategyId('')
}
const handleClose = () => { if (name || rules || code) persist(); onClose() }
// Step 1: 生成
const handleGenerate = async () => {
if (!name.trim() || !rules.trim()) return
if (!aiStatus?.configured) { setError('AI 未配置,请在设置页面配置 API Key'); return }
setLoading(true); setError('')
try {
const id = strategyId || slugId()
setStrategyId(id)
const res = await api.strategyBuild(1, { name: name.trim(), description: description.trim(), direction, rules: rules.trim(), strategy_id: id })
if (!res.valid) { setError(res.error ?? '生成失败'); return }
setCode(res.code); setStep(2)
const genDesc = parseMetaField(res.code, 'description')
const genRules = parseRules(res.code)
if (genDesc) setDescription(genDesc)
if (genRules) setRules(genRules)
await api.strategySaveCode(id, res.code)
if (genRules) { const sr = storage.strategyRules.get({}); sr[id] = genRules; storage.strategyRules.set(sr) }
} catch (e: any) {
const msg = String(e?.message ?? '')
setError(msg.includes('API Key') || msg.includes('api_key') ? 'AI API Key 未配置或无效' : (msg || '生成失败'))
} finally { setLoading(false) }
}
// Step 2: AI 修改
const handleModify = async () => {
if (!instruction.trim() || !code) return
setLoading(true); setError('')
try {
const res = await api.strategyBuild(2, { current_code: code, instruction: instruction.trim() })
if (!res.valid) { setError(res.error ?? '修改失败'); return }
setCode(res.code); setInstruction('')
const genDesc = parseMetaField(res.code, 'description')
const updatedRules = parseRules(res.code)
if (genDesc) setDescription(genDesc)
if (updatedRules) setRules(updatedRules)
const idMatch = res.code.match(/"id"\s*:\s*"([^"]+)"/)
if (idMatch) {
await api.strategySaveCode(idMatch[1], res.code)
const sr = storage.strategyRules.get({}); sr[idMatch[1]] = updatedRules; storage.strategyRules.set(sr)
}
} catch (e: any) { setError(String(e?.message ?? '修改失败')) }
finally { setLoading(false) }
}
// 手动保存
const handleSave = async () => {
if (!code) return
setSaving(true)
try {
const idMatch = code.match(/"id"\s*:\s*"([^"]+)"/)
const id = idMatch?.[1] || strategyId || slugId()
await api.strategySaveCode(id, code)
const genRules = parseRules(code)
const finalRules = (genRules || rules).trim()
if (finalRules) { const saved = storage.strategyRules.get({}); saved[id] = finalRules; storage.strategyRules.set(saved) }
await onSavedId?.(id)
clearDraft()
setTimeout(() => onClose(), 1000)
} catch (e: any) { setError(String(e?.message ?? '保存失败')) }
setSaving(false)
}
if (!open) return null
const params = parseParams(code)
const entrySignals = parseStringList(code, 'ENTRY_SIGNALS')
const exitSignals = parseStringList(code, 'EXIT_SIGNALS')
const scoring = parseScoring(code)
const stopMatch = code.match(/STOP_LOSS\s*=\s*(-?[0-9.]+|None)/)
const codeStopLoss = stopMatch ? (stopMatch[1] === 'None' ? null : parseFloat(stopMatch[1])) : null
const holdMatch = code.match(/MAX_HOLD_DAYS\s*=\s*(-?[0-9.]+|None)/)
const codeHoldDays = holdMatch ? (holdMatch[1] === 'None' ? null : parseInt(holdMatch[1])) : null
const hasParams = params.length > 0 || entrySignals.length > 0 || exitSignals.length > 0 || Object.keys(scoring).length > 0
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/40 backdrop-blur-sm"
onClick={e => { if (e.target === e.currentTarget) handleClose() }}>
<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 }}
className="w-[820px] max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
{/* 标题 */}
<div className="grid grid-cols-[1fr_auto_1fr] items-center px-5 py-2.5 border-b border-border/50">
{/* 左侧:Tab 切换 */}
<div className="flex rounded-lg bg-elevated p-0.5 w-fit">
<button onClick={() => setTab('ai')} className={cn('px-3 py-1 rounded-md text-xs font-medium transition-all cursor-pointer', tab === 'ai' ? 'bg-amber-400/15 text-amber-400' : 'text-muted hover:text-foreground')}>
<Sparkles className="h-3 w-3 inline mr-1" />AI
</button>
<button onClick={() => setTab('custom')} className={cn('px-3 py-1 rounded-md text-xs font-medium transition-all cursor-pointer', tab === 'custom' ? 'bg-accent/15 text-accent' : 'text-muted hover:text-foreground')}>
<FileText className="h-3 w-3 inline mr-1" />
</button>
</div>
{/* 中间:标题 */}
<span className="text-sm font-semibold text-foreground">
{strategyId ? '修改策略' : '创建策略'}
</span>
{/* 右侧:步骤 + 关闭 */}
<div className="flex items-center justify-end gap-2">
{tab === 'ai' && (
<div className="flex items-center gap-1">
<span className={'w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ' + (step === 1 ? 'bg-amber-400/20 text-amber-400' : 'bg-emerald-400/20 text-emerald-400')}>1</span>
<span className="text-muted/20 text-[10px]"></span>
<span className={'w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ' + (step === 2 ? 'bg-amber-400/20 text-amber-400' : 'bg-border/50 text-muted')}>2</span>
</div>
)}
<button onClick={handleClose} className="p-1.5 rounded-lg hover:bg-elevated"><X className="h-4 w-4 text-muted" /></button>
</div>
</div>
{/* Tab 描述 */}
<div className="px-5 py-2 border-b border-border/30 bg-elevated/30">
{tab === 'ai' ? (
<div className="flex items-center gap-2 text-[11px]">
<Sparkles className="h-3.5 w-3.5 text-amber-400 shrink-0" />
<span className="text-amber-400/80"> 1 2 </span>
</div>
) : (
<div className="flex items-center gap-2 text-[11px]">
<Terminal className="h-3.5 w-3.5 text-accent shrink-0" />
<span className="text-muted"> Python </span>
</div>
)}
</div>
{/* 内容 */}
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-4">
{tab === 'ai' ? (<>
{aiStatus && !aiStatus.configured && (
<div className="rounded-xl border border-amber-400/30 bg-amber-400/5 px-4 py-3 flex items-center gap-3">
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" />
<div className="flex-1 text-xs text-amber-400/80">AI API Key </div>
<button onClick={() => { persist(); window.location.href = '/settings?tab=ai' }}
className="h-7 px-3 rounded-lg bg-amber-400/15 border border-amber-400/30 text-amber-400 text-xs font-medium flex items-center gap-1.5 hover:bg-amber-400/20 shrink-0">
<Settings2 className="h-3 w-3" />
</button>
</div>
)}
{step === 1 ? (
<>
<div className="space-y-2">
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="策略名称,如:强势反包"
className="w-full h-9 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm font-medium text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30" />
<input type="text" value={description} onChange={e => setDescription(e.target.value)} placeholder="一句话描述,如:筛选前日阴线下跌、今日放量阳线反包的短线强势股"
className="w-full h-8 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30" />
</div>
<div>
<span className="text-[10px] text-muted/50 uppercase tracking-wider mb-1.5 block"></span>
<div className="flex gap-1">
{DIRECTIONS.map(d => (
<button key={d.value} onClick={() => setDirection(d.value)} className={'px-2.5 py-1 rounded text-[11px] font-medium border transition-colors ' + (direction === d.value ? 'border-amber-400/40 bg-amber-400/10 text-amber-400' : 'border-border bg-base text-muted hover:border-amber-400/30')}>{d.label}</button>
))}
</div>
</div>
<div>
<span className="text-[10px] text-muted/50 uppercase tracking-wider mb-1.5 block"></span>
<textarea value={rules} onChange={e => setRules(e.target.value)}
placeholder="描述你的选股逻辑,AI 会自动提取参数。例如:\n前一交易日为明显阴线且跌幅不低于2%,今日阳线收盘反包前一日实体,收盘价接近或高于前一日高点,成交量较前一日放大1.2倍以上,当前 close > ma5 或 close > ma10;使用 filter_history,并优先用 Polars shift/with_columns/filter 实现。"
className="w-full h-28 px-3 py-2 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground placeholder:text-muted/30 resize-none focus:outline-none focus:ring-2 focus:ring-accent/30" />
</div>
{error && <div className="text-[11px] text-danger bg-danger/10 border border-danger/20 rounded-lg px-3 py-2">{error}</div>}
<button onClick={handleGenerate} disabled={loading || !name.trim() || !rules.trim()}
className="w-full h-10 rounded-xl bg-gradient-to-r from-amber-500/20 to-amber-500/10 border border-amber-400/30 text-amber-400 text-sm font-medium flex items-center justify-center gap-2 hover:from-amber-500/30 hover:to-amber-500/20 disabled:opacity-40 transition-all">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{loading ? 'AI 生成中...' : code ? '重新生成' : 'AI 生成策略'}
</button>
</>
) : (
<>
{/* Tab 切换 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-0.5">
<button onClick={() => setPreviewTab('params')} className={'px-3 py-1 rounded text-xs font-medium transition-colors ' + (previewTab === 'params' ? 'bg-amber-400/15 text-amber-400' : 'text-muted hover:text-secondary')}></button>
<button onClick={() => setPreviewTab('code')} className={'px-3 py-1 rounded text-xs font-medium transition-colors ' + (previewTab === 'code' ? 'bg-amber-400/15 text-amber-400' : 'text-muted hover:text-secondary')}></button>
</div>
</div>
{previewTab === 'params' ? (
hasParams ? (
<div className="rounded-xl border border-border/30 bg-surface/30 overflow-hidden max-h-96 overflow-y-auto">
<div className="divide-y divide-border/10">
{params.length > 0 && (
<div className="px-4 py-3 space-y-2">
<div className="text-[10px] text-muted/50 uppercase tracking-wider"></div>
{params.map((p: any) => (
<div key={p.id} className="flex items-center gap-2">
<span className="text-[11px] text-secondary w-24 shrink-0 text-right">{p.label}</span>
{p.type === 'bool' ? (
<span className={'text-[11px] font-mono ' + (p.default ? 'text-accent' : 'text-muted')}>{p.default ? '✓ 是' : '✗ 否'}</span>
) : (
<>
<span className="text-[11px] font-mono text-foreground">{p.default}</span>
<span className="text-[10px] text-muted">{p.min} ~ {p.max}{p.type === 'float' || p.type === 'int' ? ' · 步长 ' + p.step : ''}</span>
</>
)}
</div>
))}
</div>
)}
{(entrySignals.length > 0 || exitSignals.length > 0) && (
<div className="px-4 py-3 space-y-2">
<div className="text-[10px] text-muted/50 uppercase tracking-wider"></div>
{entrySignals.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-[10px] text-emerald-400 w-10 shrink-0"></span>
<div className="flex flex-wrap gap-0.5">
{entrySignals.map((s: string) => <span key={s} className="px-1.5 py-0.5 rounded bg-emerald-400/10 text-emerald-400 text-[10px] font-mono">{s}</span>)}
</div>
</div>
)}
{exitSignals.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-[10px] text-danger w-10 shrink-0"></span>
<div className="flex flex-wrap gap-0.5">
{exitSignals.map((s: string) => <span key={s} className="px-1.5 py-0.5 rounded bg-danger/10 text-danger text-[10px] font-mono">{s}</span>)}
</div>
</div>
)}
<div className="flex items-center gap-4 text-[10px] text-muted mt-1">
{codeStopLoss !== null && <span>: {(codeStopLoss * 100).toFixed(1)}%</span>}
{codeHoldDays !== null && <span>: {codeHoldDays} </span>}
</div>
</div>
)}
{Object.keys(scoring).length > 0 && (
<div className="px-4 py-3 space-y-2">
<div className="text-[10px] text-muted/50 uppercase tracking-wider"></div>
{Object.entries(scoring).map(([k, v]) => (
<div key={k} className="flex items-center gap-2">
<span className="text-[10px] text-muted w-24 shrink-0 text-right font-mono">{k}</span>
<div className="flex-1 h-1.5 bg-elevated rounded-full overflow-hidden">
<div className="h-full bg-amber-400/60 rounded-full" style={{ width: Math.min(v * 100, 100) + '%' }} />
</div>
<span className="w-8 text-right text-[10px] font-mono text-muted">{Math.round(v * 100)}%</span>
</div>
))}
</div>
)}
</div>
</div>
) : (
<div className="rounded-xl border border-border/30 bg-surface/30 px-4 py-6 text-[11px] text-muted text-center"></div>
)
) : (
<pre className="bg-base border border-border/30 rounded-lg p-3 text-[11px] font-mono text-foreground/80 overflow-auto max-h-96 whitespace-pre-wrap">{code}</pre>
)}
{error && <div className="text-[11px] text-danger bg-danger/10 border border-danger/20 rounded-lg px-3 py-2">{error}</div>}
<div className="flex gap-2">
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleModify()}
placeholder="调整策略逻辑,如:增加RSI超卖条件、要求今日放量、修改均线为30日..."
className="flex-1 h-9 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30" />
<button onClick={handleModify} disabled={loading || !instruction.trim()}
className="h-9 px-4 rounded-lg bg-amber-400/15 border border-amber-400/30 text-amber-400 text-xs font-medium flex items-center gap-1.5 hover:bg-amber-400/20 disabled:opacity-40 transition-all">
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
AI
</button>
</div>
<p className="text-[10px] text-muted/40"></p>
</>
)}
</>
) : (
/* 自定义编写 */
<div className="space-y-4">
<div className="rounded-xl border border-border/40 bg-elevated/50 p-4 space-y-2.5">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-foreground"></span>
</div>
<div className="space-y-1.5 text-[11px] text-secondary leading-relaxed">
<p> <code className="px-1 py-0.5 rounded bg-base text-xs font-mono text-foreground/80">data/strategies/custom/</code> <code className="px-1 py-0.5 rounded bg-base text-xs font-mono text-foreground/80">.py</code> </p>
<div className="space-y-1 pl-1">
<div className="flex items-start gap-1.5">
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-accent/60 shrink-0" />
<span><strong className="text-foreground/80"> A</strong> <code className="text-[10px] font-mono text-foreground/80">filter(df, params) pl.Expr</code></span>
</div>
<div className="flex items-start gap-1.5">
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-amber-400/60 shrink-0" />
<span><strong className="text-foreground/80"> B</strong> <code className="text-[10px] font-mono text-foreground/80">filter_history(df, params) pl.DataFrame</code> + <code className="text-[10px] font-mono text-foreground/80">LOOKBACK_DAYS</code></span>
</div>
</div>
<p> <span className="text-accent">docs/strategy-guide.md</span></p>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground"></span>
<button onClick={() => { navigator.clipboard.writeText(CUSTOM_TEMPLATE); setCustomCopied(true); setTimeout(() => setCustomCopied(false), 2000) }}
className={cn('inline-flex items-center gap-1 px-2 py-1 rounded text-[10px] font-medium transition-all cursor-pointer', customCopied ? 'bg-emerald-400/10 text-emerald-400' : 'bg-elevated text-muted hover:text-foreground hover:bg-accent/10')}>
{customCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{customCopied ? '已复制' : '复制模板'}
</button>
</div>
<pre className="rounded-xl border border-border/40 bg-base p-4 text-[10px] leading-relaxed font-mono text-foreground/70 overflow-auto max-h-[400px]">{CUSTOM_TEMPLATE}</pre>
</div>
</div>
)}
</div>
{/* 底部 */}
{tab === 'ai' && (
<div className="flex items-center justify-between px-5 py-3 border-t border-border/50 bg-surface/50">
<button onClick={clearDraft} className="text-[10px] text-muted/40 hover:text-danger transition-colors"></button>
<div className="flex items-center gap-2">
{step === 1 && code && name.trim() && (
<button onClick={() => setStep(2)} className="h-7 px-3 rounded-lg border border-border text-xs text-secondary hover:text-foreground flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
</button>
)}
{step === 2 && (
<>
<button onClick={() => setStep(1)} className="h-7 px-3 rounded-lg border border-border text-xs text-secondary hover:text-foreground flex items-center gap-1">
<ChevronLeft className="h-3 w-3" />
</button>
<button onClick={handleSave} disabled={saving || loading}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-50 transition-all">
<Save className="h-3 w-3" />
{saving ? '保存中...' : '保存策略'}
</button>
</>
)}
</div>
</div>
)}
</motion.div>
</motion.div>
</AnimatePresence>
)
}
@@ -0,0 +1,223 @@
import { Settings2, TrendingDown, RadioTower } from 'lucide-react'
import { motion } from 'framer-motion'
import { storage } from '@/lib/storage'
// ===== 卡片尺寸 =====
export type CardSize = 'mini' | 'normal' | 'large' | 'hidden'
export function loadCardSize(): CardSize {
const v = storage.screenerCardSize.get('normal')
if (v === 'mini' || v === 'normal' || v === 'large' || v === 'hidden') return v
return 'normal'
}
const CARD_STYLES: Record<CardSize, {
wrap: string
card: string
name: string
count: string
desc: string
icon: string
}> = {
mini: {
wrap: 'gap-1',
card: 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full',
name: 'text-[10px]',
count: 'text-[11px]',
desc: '',
icon: 'h-3 w-3',
},
normal: {
wrap: 'gap-2',
card: 'relative inline-flex items-center gap-2 pl-3 pr-12 py-1.5 rounded-lg',
name: 'text-xs',
count: 'text-xs',
desc: 'text-[10px] text-muted leading-tight mt-0.5 line-clamp-1 max-w-[120px]',
icon: 'h-3.5 w-3.5',
},
large: {
wrap: 'gap-2',
card: 'relative inline-flex flex-col items-start pl-3.5 pr-12 py-2.5 rounded-btn min-w-[100px]',
name: 'text-xs',
count: 'text-lg font-mono font-bold tabular-nums',
desc: 'text-[10px] text-muted leading-tight mt-0.5 line-clamp-2 max-w-[140px]',
icon: 'h-3.5 w-3.5',
},
hidden: {
wrap: '',
card: '',
name: '',
count: '',
desc: '',
icon: '',
},
}
export { CARD_STYLES }
/** 获取卡片容器的 flex-wrap gap 样式 */
export function cardWrapCls(size: CardSize): string {
return `flex flex-wrap ${CARD_STYLES[size].wrap}`
}
// ===== 来源标签 =====
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
const BADGE_CLS_MAP: Record<string, string> = {
builtin: 'bg-secondary/10 text-muted border-border',
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
}
// ===== 策略卡片 =====
interface StrategyCardProps {
name: string
description?: string
source?: string
active: boolean
count?: number
/** 今日曾命中总数 */
everMatched?: number
/** 今日已失效数 (曾命中 - 当前命中) */
expiredCount?: number
loading: boolean
cardSize: CardSize
onRun: () => void
disabled: boolean
onSettings: () => void
/** 是否已加入策略监控 */
monitored?: boolean
/** 切换策略监控 (点击 RadioTower 图标) */
onToggleMonitor?: () => void
}
export function StrategyCard({
name, description, source, active, count, expiredCount,
loading, cardSize,
onRun, disabled, onSettings, monitored, onToggleMonitor,
}: StrategyCardProps) {
const cs = CARD_STYLES[cardSize]
const activeCls = active
? 'border-accent/50 bg-accent/10 shadow-[0_0_10px_rgba(59,130,246,0.1)]'
: 'border-border bg-surface hover:border-accent/40 hover:bg-accent/[0.03]'
const countCls = count === 0
? 'text-muted'
: 'bg-gradient-to-r from-amber-400 to-orange-500 bg-clip-text text-transparent'
const srcLabel = cardSize === 'mini' ? (SRC_MAP[source ?? ''] ?? '内') : (SRC_MAP[source ?? ''] ?? '内置')
const badgeCls = BADGE_CLS_MAP[source ?? 'builtin'] ?? BADGE_CLS_MAP.builtin
// 失效数 > 0 时显示
const hasExpired = expiredCount != null && expiredCount > 0
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
className={`${cs.card} border transition-all duration-150 text-left group ${activeCls}`}
>
{cardSize === 'large' ? (
<>
<button onClick={onRun} disabled={disabled}
className="flex flex-col items-start cursor-pointer disabled:opacity-50 disabled:cursor-wait w-full">
<div className="flex items-center gap-1.5 max-w-full">
<span className={`text-[9px] px-1 py-px rounded border font-medium leading-tight shrink-0 ${badgeCls}`}>{srcLabel}</span>
<span className="text-xs font-medium truncate text-foreground">{name}</span>
</div>
{description && (
<span className="text-[10px] text-muted leading-tight mt-0.5 line-clamp-1">{description}</span>
)}
{count != null && (
<div className="mt-1.5 flex items-center gap-2">
<div className="flex items-center gap-1">
<span className={`text-sm font-mono font-bold tabular-nums ${countCls}`}>{count}</span>
<span className="text-[10px] text-muted"></span>
</div>
{hasExpired && (
<div className="flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-red-500/8 border border-red-500/15">
<TrendingDown className="h-2.5 w-2.5 text-red-400" />
<span className="text-[10px] font-mono font-medium text-red-400">{expiredCount}</span>
</div>
)}
</div>
)}
{loading && <div className="mt-1 h-4 w-10 rounded bg-elevated animate-pulse" />}
</button>
<button onClick={(e) => { e.stopPropagation(); onSettings() }}
className="absolute top-1.5 right-1.5 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title="策略设置">
<Settings2 className="h-3 w-3 text-muted hover:text-accent transition-colors" />
</button>
{onToggleMonitor && (
<button onClick={(e) => { e.stopPropagation(); onToggleMonitor() }}
className="absolute top-1.5 right-7 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title={monitored ? '取消策略监控' : '开启策略监控'}>
<RadioTower className={`relative h-3 w-3 transition-colors ${monitored ? 'text-accent' : 'text-muted hover:text-accent'}`} />
{monitored && <span className="absolute inset-0 rounded animate-ping bg-accent/20" />}
</button>
)}
</>
) : cardSize === 'normal' ? (
<>
<button onClick={onRun} disabled={disabled}
className="flex flex-col items-start cursor-pointer disabled:opacity-50 disabled:cursor-wait min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className={`text-[9px] px-1 py-px rounded border font-medium leading-tight shrink-0 ${badgeCls}`}>{srcLabel}</span>
<span className="text-xs font-medium truncate text-foreground">{name}</span>
{count != null && (
<span className={`text-xs font-mono font-bold tabular-nums shrink-0 ${countCls}`}>{count}</span>
)}
{loading && <span className="w-5 h-3 rounded bg-elevated animate-pulse shrink-0" />}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{description && (
<span className="text-[10px] text-muted leading-tight line-clamp-1 max-w-[120px]">{description}</span>
)}
{hasExpired && (
<span className="text-[9px] font-mono text-red-400/80">{'-' + expiredCount}</span>
)}
</div>
</button>
<button onClick={(e) => { e.stopPropagation(); onSettings() }}
className="absolute top-1.5 right-1.5 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title="策略设置">
<Settings2 className="h-3 w-3 text-muted hover:text-accent transition-colors" />
</button>
{onToggleMonitor && (
<button onClick={(e) => { e.stopPropagation(); onToggleMonitor() }}
className="absolute top-1.5 right-7 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title={monitored ? '取消策略监控' : '开启策略监控'}>
<RadioTower className={`relative h-3 w-3 transition-colors ${monitored ? 'text-accent' : 'text-muted hover:text-accent'}`} />
{monitored && <span className="absolute inset-0 rounded animate-ping bg-accent/20" />}
</button>
)}
</>
) : (
/* mini */
<>
<button onClick={onRun} disabled={disabled}
className="flex items-center gap-1 cursor-pointer disabled:opacity-50 disabled:cursor-wait">
<span className="text-[8px] px-0.5 rounded bg-secondary/10 text-muted border border-border font-medium leading-tight">{srcLabel}</span>
<span className="text-[10px] font-medium whitespace-nowrap text-foreground">{name}</span>
{count != null && (
<span className={`text-xs font-mono font-bold tabular-nums ${countCls}`}>{count}</span>
)}
{hasExpired && (
<span className="text-[9px] font-mono text-red-400/70">{'-' + expiredCount}</span>
)}
{loading && <span className="w-4 h-2.5 rounded bg-elevated animate-pulse" />}
</button>
{onToggleMonitor && (
<button onClick={(e) => { e.stopPropagation(); onToggleMonitor() }}
className="relative p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title={monitored ? '取消策略监控' : '开启策略监控'}>
<RadioTower className={`h-3 w-3 transition-colors ${monitored ? 'text-accent' : 'text-muted hover:text-accent'}`} />
{monitored && <span className="absolute inset-0 rounded animate-ping bg-accent/20" />}
</button>
)}
<button onClick={(e) => { e.stopPropagation(); onSettings() }}
className="p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title="策略设置">
<Settings2 className="h-3 w-3 text-muted hover:text-accent transition-colors" />
</button>
</>
)}
</motion.div>
)
}
@@ -0,0 +1,240 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { motion, AnimatePresence, Reorder } from 'framer-motion'
import { X, Plus, GripVertical } from 'lucide-react'
import { api, type StrategyDetail } from '@/lib/api'
interface Props {
pool: string[]
onConfirm: (newPool: string[]) => void
onClose: () => void
}
const SOURCE_CLS: Record<string, string> = {
builtin: 'bg-accent/10 text-accent border-accent/20',
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
invalid: 'bg-danger/10 text-danger border-danger/20',
}
const SOURCE_LABEL: Record<string, string> = {
builtin: '内置',
custom: '自定义',
ai: 'AI',
invalid: '失效',
}
type SourceTab = 'all' | 'builtin' | 'custom' | 'ai'
const TABS: { id: SourceTab; label: string }[] = [
{ id: 'all', label: '全部' },
{ id: 'builtin', label: '内置' },
{ id: 'custom', label: '自定义' },
{ id: 'ai', label: 'AI' },
]
export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
// 草稿状态: 打开时从 pool 复制, 操作只改草稿, 点确定才提交
const [draftPool, setDraftPool] = useState<string[]>(() => [...pool])
const [allStrategies, setAllStrategies] = useState<StrategyDetail[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<SourceTab>('all')
useEffect(() => {
api.strategyList()
.then(d => setAllStrategies(d.strategies))
.catch(() => setAllStrategies([]))
.finally(() => setLoading(false))
}, [])
const stratMap = useMemo(() => {
const m = new Map<string, StrategyDetail>()
allStrategies.forEach(s => m.set(s.id, s))
return m
}, [allStrategies])
const validDraft = useMemo(
() => draftPool.filter(id => stratMap.has(id)),
[draftPool, stratMap]
)
const invalidPoolCount = draftPool.length - validDraft.length
const available = useMemo(
() => allStrategies.filter(s => !draftPool.includes(s.id)),
[allStrategies, draftPool]
)
// 按 Tab 分组过滤待选
const filteredAvailable = useMemo(() => {
if (activeTab === 'all') return available
return available.filter(s => s.source === activeTab)
}, [available, activeTab])
const handleAdd = useCallback((id: string) => {
setDraftPool(prev => prev.includes(id) ? prev : [...prev, id])
}, [])
const handleRemove = useCallback((id: string) => {
setDraftPool(prev => prev.filter(x => x !== id))
}, [])
const handleReorder = useCallback((newOrder: string[]) => {
setDraftPool(newOrder)
}, [])
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-[680px] max-h-[78vh] 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">
<span className="text-sm font-medium text-foreground">
<span className="text-muted font-normal text-xs">{validDraft.length} / {allStrategies.length}</span>
{invalidPoolCount > 0 && <span className="ml-2 text-[10px] text-danger">{invalidPoolCount} </span>}
</span>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
<X className="h-4 w-4 text-muted" />
</button>
</div>
{loading ? (
<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>
) : (
<div className="flex-1 min-h-0 grid grid-cols-2 gap-0">
{/* 左侧: 待选 (Tab 分组) */}
<div className="flex flex-col min-h-0 border-r border-border">
<div className="flex items-center gap-0.5 px-3 py-2 border-b border-border/60 shrink-0">
{TABS.map(tab => {
const count = tab.id === 'all'
? available.length
: available.filter(s => s.source === tab.id).length
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-btn transition-colors cursor-pointer ${
activeTab === tab.id
? 'bg-accent/10 text-accent'
: 'text-muted hover:text-secondary hover:bg-elevated'
}`}
>
{tab.label}
<span className="ml-1 text-[9px] opacity-60">{count}</span>
</button>
)
})}
</div>
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
{filteredAvailable.length === 0 ? (
<div className="flex items-center justify-center h-full text-[11px] text-muted">
{available.length === 0 ? '全部已加入策略池' : '此分组无待选策略'}
</div>
) : filteredAvailable.map(s => (
<button
key={s.id}
onClick={() => handleAdd(s.id)}
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-btn
hover:bg-accent/8 transition-colors cursor-pointer group text-left"
>
<span className="flex-1 min-w-0">
<span className="text-[12px] text-foreground group-hover:text-accent transition-colors block truncate">{s.name}</span>
<span className="text-[10px] text-muted truncate block">{s.description}</span>
</span>
<span className={`text-[8px] px-1 py-px rounded border leading-tight shrink-0 ${SOURCE_CLS[s.source] ?? SOURCE_CLS.builtin}`}>
{SOURCE_LABEL[s.source] ?? '内置'}
</span>
<Plus className="h-3.5 w-3.5 text-muted/40 group-hover:text-accent shrink-0" />
</button>
))}
</div>
</div>
{/* 右侧: 已选 (Reorder.Group 纵向拖拽) */}
<div className="flex flex-col min-h-0">
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/60 shrink-0">
<GripVertical className="h-3 w-3 text-muted/50" />
<span className="text-[10px] text-muted"> · </span>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{draftPool.length === 0 ? (
<div className="flex items-center justify-center h-full text-[11px] text-muted">
</div>
) : (
<Reorder.Group
axis="y"
values={draftPool}
onReorder={handleReorder}
className="space-y-1"
>
{draftPool.map(id => {
const s = stratMap.get(id)
const src = s?.source ?? 'invalid'
return (
<Reorder.Item
key={id}
value={id}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-btn
bg-accent/8 border border-accent/20
cursor-grab active:cursor-grabbing
hover:bg-accent/15 transition-colors group"
whileDrag={{ scale: 1.02, zIndex: 50, boxShadow: '0 4px 12px rgba(0,0,0,0.2)' }}
>
<GripVertical className="h-3.5 w-3.5 text-accent/40 group-hover:text-accent/70 shrink-0" />
<span className="flex-1 min-w-0 text-[12px] text-foreground truncate">{s?.name ?? id}</span>
<span className={`text-[8px] px-1 py-px rounded border leading-tight shrink-0 ${SOURCE_CLS[src] ?? SOURCE_CLS.builtin}`}>
{SOURCE_LABEL[src] ?? '内置'}
</span>
<button
onClick={(e) => { e.stopPropagation(); handleRemove(id) }}
className="text-muted/40 hover:text-danger transition-colors cursor-pointer leading-none shrink-0"
title="移除"
>
<X className="h-3.5 w-3.5" />
</button>
</Reorder.Item>
)
})}
</Reorder.Group>
)}
</div>
</div>
</div>
)}
{/* 底部 */}
<div className="flex items-center justify-between px-4 py-2 border-t border-border shrink-0">
<span className="text-[10px] text-muted"></span>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-3 py-1 text-xs rounded-btn border border-border text-muted hover:text-foreground hover:border-border/80 transition-colors cursor-pointer"
>
</button>
<button
onClick={() => { onConfirm(draftPool); onClose() }}
className="px-3 py-1 text-xs rounded-btn bg-accent text-white hover:bg-accent/90 transition-colors cursor-pointer"
>
</button>
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
@@ -0,0 +1,609 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react'
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
import { color } from '@/lib/colors'
import { SignalPicker } from './SignalPicker'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
// 内置列名 → 中文标签
const FIELD_LABEL: Record<string, string> = {}
for (const c of BUILTIN_COLUMNS) {
if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label
}
// enriched 列名别名
Object.assign(FIELD_LABEL, {
change_pct: '涨跌幅', consecutive_limit_ups: '连板',
momentum_60d: '60D动量', turnover_rate: '换手率',
rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24',
vol_ratio_5d: '量比', vol_ratio_20d: '20日量比',
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
boll_upper: '布林上轨', boll_lower: '布林下轨',
})
interface Props {
strategyId: string | null
onClose: () => void
onSaved?: (displayLimit: number | null) => void
onAiModify?: () => void
onDeleted?: () => void
}
// ===== 可折叠区域 =====
function Section({ icon: Icon, title, accent, defaultOpen = true, children, extra }: {
icon?: React.ComponentType<{ className?: string }>
title: string
accent?: string
defaultOpen?: boolean
children: React.ReactNode
extra?: React.ReactNode
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="rounded-xl border border-border/15 bg-surface/20 overflow-hidden">
<div className="flex items-center gap-2 px-3.5 py-2 hover:bg-surface/30 transition-colors">
<button
onClick={() => setOpen(v => !v)}
className="flex min-w-0 flex-1 items-center gap-2 text-left cursor-pointer"
>
<ChevronDown className={`h-3 w-3 text-muted/40 transition-transform duration-200 ${open ? '' : '-rotate-90'}`} />
{Icon && <Icon className={`h-3.5 w-3.5 ${accent ?? 'text-muted'}`} />}
<span className="text-[11px] font-medium text-foreground/70">{title}</span>
</button>
{extra && <div className="ml-auto flex items-center gap-1">{extra}</div>}
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="px-3.5 pb-3 pt-0.5 space-y-2">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
// ===== 区间字段(最小 ~ 最大) =====
function RangeField({ label, minVal, maxVal, onMinChange, onMaxChange, unit, step }: {
label: string
minVal: any
maxVal: any
onMinChange: (v: any) => void
onMaxChange: (v: any) => void
unit?: string
step?: string
}) {
return (
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{label}</span>
<input
type="number"
value={minVal ?? ''}
onChange={e => onMinChange(e.target.value === '' ? null : Number(e.target.value))}
placeholder="最小"
step={step}
className="w-20 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
<span className="text-[10px] text-muted">~</span>
<input
type="number"
value={maxVal ?? ''}
onChange={e => onMaxChange(e.target.value === '' ? null : Number(e.target.value))}
placeholder="最大"
step={step}
className="w-20 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
{unit && <span className="text-[10px] text-muted shrink-0">{unit}</span>}
</div>
)
}
// 板块标签
const ALL_BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所']
// 策略参数字段
function ParamField({ def, value, onChange }: {
def: StrategyParamDef
value: any
onChange: (v: any) => void
}) {
if (def.type === 'bool') {
const checked = value === true || value === 'true' || value === 'True'
return (
<div className="flex items-center gap-2">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{def.label}</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors duration-200 cursor-pointer ${
checked ? 'bg-accent' : 'bg-elevated'
}`}
aria-pressed={checked}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
)
}
if (def.type === 'select' && def.options) {
return (
<div className="flex items-center gap-2">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{def.label}</span>
<select
value={value ?? def.default}
onChange={e => onChange(e.target.value)}
className="w-24 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground focus:outline-none focus:border-accent/50"
>
{def.options.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
)
}
return (
<div className="flex items-center gap-2">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{def.label}</span>
<input
type="number"
value={value ?? def.default}
onChange={e => onChange(e.target.value === '' ? def.default : Number(e.target.value))}
step={def.step ?? 0.1}
min={def.min}
max={def.max}
className="w-20 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
{def.min != null && def.max != null && (
<span className="text-[10px] text-muted">{def.min}~{def.max}</span>
)}
</div>
)
}
// 评分权重字段
function ScoringField({ col, weight, pct, editing, onChange }: {
col: string; weight: number; pct: number; editing: boolean; onChange: (v: number) => void
}) {
return (
<div className="flex items-center gap-2">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{FIELD_LABEL[col] ?? col}</span>
{editing ? (
<input
type="range"
value={weight}
onChange={e => onChange(Number(e.target.value))}
min={0} max={100} step={1}
className="flex-1 h-1 accent-amber-400 cursor-pointer"
/>
) : (
<div className="flex-1 h-1.5 bg-elevated rounded-full overflow-hidden">
<div className="h-full bg-amber-400/70 rounded-full transition-all duration-300" style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
)}
<span className="w-10 text-right text-[10px] font-mono text-muted">{editing ? weight : `${pct}%`}</span>
</div>
)
}
export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModify, onDeleted }: Props) {
const [detail, setDetail] = useState<StrategyDetail | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [resetting, setResetting] = useState(false)
// 编辑状态
const [strategyName, setStrategyName] = useState('')
const [strategyDesc, setStrategyDesc] = useState('')
const [basicFilter, setBasicFilter] = useState<Record<string, any>>({})
const [params, setParams] = useState<Record<string, any>>({})
const [scoring, setScoring] = useState<Record<string, number>>({})
const [stopLoss, setStopLoss] = useState<number | null>(null)
const [maxHoldDays, setMaxHoldDays] = useState<number | null>(null)
const [entrySignals, setEntrySignals] = useState<string[]>([])
const [exitSignals, setExitSignals] = useState<string[]>([])
const [displayLimit, setDisplayLimit] = useState<number | null>(null)
const [basicFilterEnabled, setBasicFilterEnabled] = useState(true)
const [editingScoring, setEditingScoring] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// 辅助:更新 basicFilter 某个 key
const setBF = useCallback((key: string, value: any) => {
setBasicFilter(prev => ({ ...prev, [key]: value }))
}, [])
// 加载策略详情
useEffect(() => {
if (!strategyId) return
setLoading(true)
api.strategyGet(strategyId)
.then(d => {
setDetail(d)
setStrategyName(d.name ?? '')
setStrategyDesc(d.description ?? '')
// 确保 boards 有默认值
const bf = { ...d.basic_filter }
if (!bf.boards) bf.boards = ALL_BOARDS
setBasicFilter(bf)
setParams(d.params_defaults)
setScoring(Object.fromEntries(Object.entries(d.scoring).map(([k, v]) => [k, Math.round((v as number) * 100)])))
setStopLoss(d.stop_loss)
setMaxHoldDays(d.max_hold_days)
setEntrySignals(d.entry_signals ?? [])
setExitSignals(d.exit_signals ?? [])
setDisplayLimit(d.display_limit ?? null)
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
})
.catch(() => setDetail(null))
.finally(() => setLoading(false))
}, [strategyId])
// 保存
const handleSave = async () => {
if (!strategyId) return
setSaving(true)
try {
await api.strategySaveConfig(strategyId, {
name: strategyName,
description: strategyDesc,
basic_filter: { ...basicFilter, enabled: basicFilterEnabled },
params,
scoring: Object.fromEntries(Object.entries(scoring).map(([k, v]) => [k, +(v / 100).toFixed(4)])),
stop_loss: stopLoss,
max_hold_days: maxHoldDays,
entry_signals: entrySignals,
exit_signals: exitSignals,
display_limit: displayLimit,
})
onSaved?.(displayLimit)
onClose()
} finally {
setSaving(false)
}
}
// 重置
const handleReset = async () => {
if (!strategyId) return
setResetting(true)
try {
await api.strategyResetConfig(strategyId)
// 重新加载默认值
const d = await api.strategyGet(strategyId)
setDetail(d)
setStrategyName(d.name ?? '')
setStrategyDesc(d.description ?? '')
const bf = { ...d.basic_filter }
if (!bf.boards) bf.boards = ALL_BOARDS
setBasicFilter(bf)
setParams(d.params_defaults)
setScoring(Object.fromEntries(Object.entries(d.scoring).map(([k, v]) => [k, Math.round((v as number) * 100)])))
setStopLoss(d.stop_loss)
setMaxHoldDays(d.max_hold_days)
setEntrySignals(d.entry_signals ?? [])
setExitSignals(d.exit_signals ?? [])
setDisplayLimit(d.display_limit ?? null)
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
} finally {
setResetting(false)
}
}
const handleDelete = async () => {
if (!strategyId) return
setDeleting(true)
try {
await api.strategyDelete(strategyId)
onDeleted?.()
onClose()
} catch { /* ignore */ }
finally { setDeleting(false); setShowDeleteConfirm(false) }
}
if (!strategyId) return null
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/40 backdrop-blur-sm"
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.2, ease: [0.16, 1, 0.3, 1] }}
className="w-[980px] max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* 标题 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border/50">
<div className="flex items-center gap-2.5">
<Settings2 className="h-4 w-4 text-accent" />
<span className="text-sm font-semibold text-foreground">{detail?.name ?? strategyId}</span>
{detail && <span className="text-[10px] px-1.5 py-0.5 rounded bg-elevated text-muted">{{ builtin: '内置', custom: '自定义', ai: 'AI' }[detail.source] ?? detail.source}</span>}
<span className="text-[10px] text-muted/40 font-mono">{strategyId}</span>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-elevated transition-colors cursor-pointer"><X className="h-4 w-4 text-muted" /></button>
</div>
{/* 内容 */}
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5">
{loading ? (
<div className="flex items-center justify-center py-16"><div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" /></div>
) : detail ? (
<>
{/* 名称 + 描述 + 显示上限 */}
<div className="flex items-end gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted/50 uppercase tracking-wider w-8 shrink-0"></span>
<input type="text" value={strategyName} onChange={e => setStrategyName(e.target.value)}
className="flex-1 h-8 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm font-medium text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted/50 uppercase tracking-wider w-8 shrink-0"></span>
<input type="text" value={strategyDesc} onChange={e => setStrategyDesc(e.target.value)}
className="flex-1 h-8 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
</div>
</div>
<div className="flex items-center gap-1.5 pb-0.5 shrink-0">
<span className="text-[10px] text-muted/50"></span>
<input type="number" value={displayLimit ?? ''} onChange={e => setDisplayLimit(e.target.value ? Number(e.target.value) : null)} step={1} min={10} max={200} placeholder="不限"
className="w-14 h-8 px-1.5 rounded-lg bg-base border border-border/40 text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<span className="text-[10px] text-muted/50"></span>
</div>
</div>
{/* 三列 */}
<div className="grid grid-cols-3 gap-5 items-start">
{/* 列1:选股条件 */}
<Section icon={Filter} title="基础参数" accent="text-sky-400">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted"></span>
<button onClick={() => setBasicFilterEnabled(v => !v)}
className={`relative w-8 h-[18px] rounded-full transition-colors ${basicFilterEnabled ? 'bg-sky-500' : 'bg-border'}`}>
<span className={`absolute top-0.5 w-3.5 h-3.5 rounded-full bg-white transition-transform ${basicFilterEnabled ? 'left-[16px]' : 'left-0.5'}`} />
</button>
</div>
<div className={`space-y-2 transition-opacity duration-200 ${basicFilterEnabled ? '' : 'opacity-25 pointer-events-none'}`}>
<RangeField label="价格" minVal={basicFilter.price_min} maxVal={basicFilter.price_max} onMinChange={v => setBF('price_min', v)} onMaxChange={v => setBF('price_max', v)} unit="元" step="1" />
<RangeField label="流通市值" minVal={basicFilter.float_cap_min != null ? basicFilter.float_cap_min / 1e8 : null} maxVal={basicFilter.float_cap_max != null ? basicFilter.float_cap_max / 1e8 : null} onMinChange={v => setBF('float_cap_min', v != null ? v * 1e8 : null)} onMaxChange={v => setBF('float_cap_max', v != null ? v * 1e8 : null)} unit="亿" step="5" />
<RangeField label="成交额" minVal={basicFilter.amount_min != null ? basicFilter.amount_min / 1e8 : null} maxVal={basicFilter.amount_max != null ? basicFilter.amount_max / 1e8 : null} onMinChange={v => setBF('amount_min', v != null ? v * 1e8 : null)} onMaxChange={v => setBF('amount_max', v != null ? v * 1e8 : null)} unit="亿" step="0.5" />
<RangeField label="换手率" minVal={basicFilter.turnover_min} maxVal={basicFilter.turnover_max} onMinChange={v => setBF('turnover_min', v)} onMaxChange={v => setBF('turnover_max', v)} unit="%" step="0.5" />
<div className="space-y-1.5">
<div className="flex items-start gap-1.5">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right pt-0.5"></span>
<div className="flex flex-wrap gap-0.5">
{ALL_BOARDS.map(b => {
const boards: string[] = basicFilter.boards ?? ALL_BOARDS
const active = boards.includes(b)
return (
<button key={b} onClick={() => { const cur: string[] = basicFilter.boards ?? ALL_BOARDS; const next = active ? cur.filter(x => x !== b) : [...cur, b]; setBF('boards', next.length === 0 ? ALL_BOARDS : next) }}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors cursor-pointer ${active ? `${color.select.border} ${color.select.bgLight} ${color.select.text}` : `border-border bg-base text-muted ${color.select.borderHover}`}`}>{b}</button>
)
})}
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">ST</span>
<button onClick={() => setBF('exclude_st', !basicFilter.exclude_st)}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors cursor-pointer ${basicFilter.exclude_st ? 'border-danger/40 bg-danger/10 text-danger' : 'border-border bg-base text-muted hover:border-danger/30'}`}>{basicFilter.exclude_st ? '排除' : '包含'}</button>
</div>
</div>
</div>
</Section>
{/* 列2:策略参数 */}
<div className="space-y-3">
{detail.params.length > 0 ? (
<Section icon={Settings2} title="策略参数" accent="text-muted">
<div className="space-y-1.5">
{detail.params.map(p => <ParamField key={p.id} def={p} value={params[p.id]} onChange={v => setParams({ ...params, [p.id]: v })} />)}
</div>
</Section>
) : (
<div className="rounded-xl border border-border/15 bg-surface/20 px-3.5 py-4 text-[11px] text-muted/50 text-center"></div>
)}
</div>
{/* 列3:评分 + 交易 */}
<div className="space-y-3">
<Section icon={Star} title="评分权重" accent="text-amber-400">
{Object.entries(scoring).length > 0 ? (() => {
const total = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
return (
<div className="space-y-2">
{Object.entries(scoring).map(([col, w]) => {
const pct = Math.round((w / total) * 100)
return (
<ScoringField key={col} col={col} weight={w} pct={pct}
editing={editingScoring}
onChange={v => setScoring({ ...scoring, [col]: Math.max(0, v) })} />
)
})}
<div className="flex items-center justify-between pt-1.5 border-t border-border/10">
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<span></span>
<span className={`font-mono font-medium text-xs ${editingScoring ? (total === 100 ? color.ok : color.scoreWarn) : color.ok}`}>{editingScoring ? total : '100'}</span>
<span className="text-muted/40"></span>
</div>
<button
onClick={() => {
if (editingScoring) {
// 确认:归一化到 100
const sum = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
const norm = Object.fromEntries(
Object.entries(scoring).map(([k, v]) => [k, Math.round((v / sum) * 100)])
)
// 修正舍入误差
const newSum = Object.values(norm).reduce((a: number, b: number) => a + b, 0)
if (newSum !== 100) {
const keys = Object.keys(norm)
norm[keys[0]] += (100 - newSum)
}
setScoring(norm)
} else {
// 进入编辑:展开为 0-100 范围
const sum = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
setScoring(Object.fromEntries(
Object.entries(scoring).map(([k, v]) => [k, Math.round((v / sum) * 100)])
))
}
setEditingScoring(v => !v)
}}
className="text-[10px] text-accent/80 hover:text-accent cursor-pointer"
>{editingScoring ? '确定' : '设置'}</button>
</div>
</div>
)
})() : <div className="text-[11px] text-muted"></div>}
</Section>
<Section icon={TrendingUp} title="交易参数" accent="text-emerald-400">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary w-12 shrink-0"></span>
<input type="number" value={stopLoss ?? ''} onChange={e => setStopLoss(e.target.value === '' ? null : Number(e.target.value))} step={0.01} min={-0.5} max={0}
className="w-16 h-6 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<span className="text-[10px] text-muted">{stopLoss != null ? `${(stopLoss * 100).toFixed(1)}%` : '—'}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary w-12 shrink-0"></span>
<input type="number" value={maxHoldDays ?? ''} onChange={e => setMaxHoldDays(e.target.value === '' ? null : Number(e.target.value))} step={1} min={1}
className="w-16 h-6 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<span className="text-[10px] text-muted"></span>
</div>
<div className="text-[11px] text-muted pt-1 border-t border-border/10">
<span className="text-secondary"> </span><span className="text-foreground/70">{entrySignals.length > 0 ? `${entrySignals.length} 个触发器` : '无'}</span>
<span className="text-secondary ml-3"> </span><span className="text-foreground/70">{exitSignals.length > 0 ? `${exitSignals.length} 个触发器` : '无'}</span>
</div>
</div>
</Section>
<Section
icon={TrendingUp}
title="买入触发器"
accent="text-accent"
defaultOpen={false}
extra={<SignalTriggerActions kind="entry" signals={entrySignals} onChange={setEntrySignals} buttonClassName="rounded-md border border-border bg-base p-1 text-muted transition-colors cursor-pointer" iconClassName="h-3 w-3" />}
>
<SignalPicker signals={entrySignals} onChange={setEntrySignals} kind="entry" variant="dialog" />
<div className="text-[10px] leading-4 text-muted/70"></div>
</Section>
<Section
icon={TrendingUp}
title="卖出触发器"
accent="text-warning"
defaultOpen={false}
extra={<SignalTriggerActions kind="exit" signals={exitSignals} onChange={setExitSignals} buttonClassName="rounded-md border border-border bg-base p-1 text-muted transition-colors cursor-pointer" iconClassName="h-3 w-3" />}
>
<SignalPicker signals={exitSignals} onChange={setExitSignals} kind="exit" variant="dialog" />
<div className="text-[10px] leading-4 text-muted/70"></div>
</Section>
<div className="rounded-xl border border-amber-400/20 bg-amber-400/[0.04] px-3 py-2 text-[10px] leading-4 text-muted">
<b className="text-secondary"></b>;,
</div>
{detail.alerts.length > 0 && (
<Section icon={Settings2} title="提醒" accent="text-muted">
<div className="space-y-1">
{detail.alerts.map((a, i) => (
<div key={i} className="text-[10px] text-secondary">{a.message} <span className="text-muted font-mono">{a.op ? `${FIELD_LABEL[a.field] ?? a.field} ${a.op} ${a.value}` : FIELD_LABEL[a.field] ?? a.field}</span></div>
))}
</div>
</Section>
)}
</div>
</div>
</>
) : (
<div className="flex items-center justify-center py-16 text-sm text-muted"></div>
)}
</div>
{/* 底部按钮 */}
<div className="flex items-center justify-between px-5 py-3 border-t border-border/50 bg-surface/50">
<div className="flex items-center gap-2">
<button onClick={handleReset} disabled={resetting}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg border border-border bg-surface text-xs text-secondary hover:text-danger hover:border-danger/30 transition-colors cursor-pointer disabled:opacity-50">
<RotateCcw className="h-3.5 w-3.5" />{resetting ? '重置中…' : '重置默认'}
</button>
{(detail?.source === 'ai' || detail?.source === 'custom') && (
<button onClick={() => setShowDeleteConfirm(true)}
className="text-[10px] text-muted/40 hover:text-danger transition-colors"></button>
)}
</div>
<div className="flex items-center gap-2">
{(detail?.source === 'ai' || detail?.source === 'custom') && (
<button onClick={onAiModify}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg border border-amber-400/30 bg-amber-400/8 text-amber-400 text-xs font-medium hover:bg-amber-400/15 transition-colors cursor-pointer">
<Sparkles className="h-3.5 w-3.5" />AI
</button>
)}
<button onClick={handleSave} disabled={saving}
className="inline-flex items-center gap-1.5 h-8 px-4 rounded-lg bg-accent text-white text-xs font-semibold hover:bg-accent/90 transition-colors cursor-pointer disabled:opacity-50">
<Save className="h-3.5 w-3.5" />{saving ? '保存中…' : '保存设置'}
</button>
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
{/* 删除确认弹窗 */}
{showDeleteConfirm && (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={() => setShowDeleteConfirm(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }}
className="w-[380px] bg-surface border border-border/50 rounded-2xl shadow-2xl p-6"
onClick={e => e.stopPropagation()}
>
<div className="text-center space-y-3">
<div className="w-10 h-10 rounded-full bg-danger/10 flex items-center justify-center mx-auto">
<span className="text-danger text-lg">!</span>
</div>
<div>
<div className="text-sm font-semibold text-foreground"></div>
<div className="text-xs text-muted mt-1">{detail?.name ?? strategyId}</div>
</div>
<div className="text-[11px] text-danger/70 bg-danger/[0.04] rounded-lg px-3 py-2 border border-danger/10">
</div>
<div className="flex gap-2 pt-2">
<button onClick={() => setShowDeleteConfirm(false)}
className="flex-1 h-8 rounded-lg border border-border text-xs text-secondary hover:text-foreground"></button>
<button onClick={handleDelete} disabled={deleting}
className="flex-1 h-8 rounded-lg bg-danger text-white text-xs font-medium hover:bg-danger/90 disabled:opacity-50">
{deleting ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)}
</>
)
}
@@ -0,0 +1,87 @@
import { motion, AnimatePresence } from 'framer-motion'
import { X, Store, Hammer, Download, Zap } from 'lucide-react'
interface Props {
open: boolean
onClose: () => void
}
/**
* 获取策略(占位)
* 目标:不定时更新更多策略。功能正在建设中,当前仅占位。
*/
export function StrategyStoreDialog({ open, onClose }: Props) {
return (
<AnimatePresence>
{open && (
<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-[560px] max-h-[78vh] 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">
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<Store className="h-4 w-4 text-accent" />
</span>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
<X className="h-4 w-4 text-muted" />
</button>
</div>
{/* 建设中占位内容 */}
<div className="flex-1 flex flex-col items-center justify-center px-6 py-14 text-center">
<div className="relative mb-5">
<div className="absolute inset-0 blur-2xl bg-amber-400/20 rounded-full" />
<div className="relative h-16 w-16 rounded-2xl bg-amber-400/10 border border-amber-400/30 flex items-center justify-center">
<Hammer className="h-8 w-8 text-amber-400" />
</div>
</div>
<h3 className="text-base font-semibold text-foreground mb-1.5">
</h3>
<p className="text-sm text-muted leading-relaxed max-w-[380px]">
</p>
<div className="mt-6 flex items-center gap-4 text-[11px] text-muted">
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5" />
</span>
<span className="w-px h-3 bg-border" />
<span className="flex items-center gap-1">
<Zap className="h-3.5 w-3.5" />
使
</span>
</div>
</div>
{/* 底部关闭 */}
<div className="flex justify-end px-4 py-2.5 border-t border-border shrink-0">
<button
onClick={onClose}
className="inline-flex items-center gap-1.5 h-7 px-4 rounded-btn
border border-border bg-surface text-xs font-medium text-secondary
hover:text-accent hover:border-accent/50 transition-colors cursor-pointer"
>
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,190 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowRight, Plus, Save, X } from 'lucide-react'
import { api, type CustomSignal, type CustomSignalCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
interface Props {
open: boolean
signal?: CustomSignal | null
defaultKind?: CustomSignal['kind']
onClose: () => void
onSaved?: (signal: CustomSignal) => void
}
const emptySignal = (kind: CustomSignal['kind'] = 'exit'): CustomSignal => ({
id: '', name: '', kind, enabled: true,
conditions: [{ left: 'close', op: '>', right: 'field:ma20' }],
})
export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose, onSaved }: Props) {
const qc = useQueryClient()
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions, enabled: open })
const [draft, setDraft] = useState<CustomSignal>(() => emptySignal(defaultKind))
const [error, setError] = useState('')
const fields = options.data?.fields ?? []
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
const editing = !!signal
useEffect(() => {
if (!open) return
setDraft(signal ? { ...signal, conditions: signal.conditions.map(c => ({ ...c })) } : emptySignal(defaultKind))
setError('')
}, [open, signal, defaultKind])
const save = useMutation({
mutationFn: () => {
const d = draft
if (!d.id.trim()) throw new Error('请输入信号标识')
if (!/^[a-z0-9_]{1,40}$/.test(d.id)) throw new Error('标识仅允许小写字母、数字、下划线(1-40字符)')
if (!d.name.trim()) throw new Error('请输入信号名称')
if (d.conditions.length === 0) throw new Error('至少需要一个条件')
for (const c of d.conditions) {
if (!c.left || !c.op || c.right === '') throw new Error('条件填写不完整')
}
return api.customSignalSave(d)
},
onSuccess: res => {
qc.invalidateQueries({ queryKey: QK.customSignals })
onSaved?.(res.signal)
onClose()
},
onError: err => setError(String((err as any)?.message ?? err)),
})
const updateCond = (idx: number, patch: Partial<CustomSignalCondition>) => {
setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
}
const addCond = () => setDraft(d => ({ ...d, conditions: [...d.conditions, { left: 'close', op: '>', right: '0' }] }))
const removeCond = (idx: number) => setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={onClose}
>
<motion.div
role="dialog"
aria-modal="true"
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.18, ease: [0.16, 1, 0.3, 1] }}
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"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-5 py-4">
<div>
<h3 className="text-sm font-semibold text-foreground">{editing ? '编辑自定义信号' : '新建自定义信号'}</h3>
<p className="mt-1 text-[11px] text-muted"> csg_* </p>
</div>
<button onClick={onClose} className="rounded-lg p-1.5 text-muted transition-colors hover:bg-elevated hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input
value={draft.id}
disabled={editing}
onChange={e => setDraft(d => ({ ...d, id: e.target.value.replace(/[^a-z0-9_]/g, '') }))}
placeholder="如 low_touches_ma5"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground disabled:opacity-60"
/>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input value={draft.name} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="如 跌至MA5" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={draft.kind} onChange={e => setDraft(d => ({ ...d, kind: e.target.value as CustomSignal['kind'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
<option value="entry"></option>
<option value="exit"></option>
<option value="both"></option>
</select>
</label>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"></span>
<button onClick={addCond} 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>
<div className="space-y-2 rounded-card border border-border/70 bg-base/50 p-3">
{draft.conditions.map((c, i) => (
<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 ? '当' : '且'}</span>
<select value={c.left} onChange={e => updateCond(i, { left: e.target.value })} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(i, { 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>
<RightValueInput cond={c} fields={fields} onChange={v => updateCond(i, { right: v })} />
{draft.conditions.length > 1 && (
<button onClick={() => removeCond(i)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
</div>
<div className="flex justify-end gap-2 border-t border-border/50 px-5 py-4">
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-amber-500/90 text-base text-xs font-medium disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
function RightValueInput({ cond, fields, onChange }: { cond: CustomSignalCondition; fields: { key: string; label: string }[]; onChange: (v: string) => void }) {
const isField = cond.right.startsWith('field:')
const fieldValue = isField ? cond.right.slice(6) : ''
const numValue = isField ? '' : cond.right
return (
<div className="flex items-center gap-1">
{isField ? (
<>
<select value={fieldValue} onChange={e => onChange(`field:${e.target.value}`)} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<button onClick={() => onChange('0')} title="切换为数字" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
<ArrowRight className="h-3 w-3 rotate-90" />
</button>
</>
) : (
<>
<input type="number" value={numValue} onChange={e => onChange(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={() => onChange('field:close')} title="切换为字段" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
<ArrowRight className="h-3 w-3 -rotate-90" />
</button>
</>
)}
</div>
)
}
@@ -0,0 +1,56 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Settings2 } from 'lucide-react'
import type { CustomSignal } from '@/lib/api'
import { CustomSignalDialog } from './CustomSignalDialog'
interface Props {
kind: 'entry' | 'exit'
signals: string[]
onChange: (next: string[]) => void
buttonClassName?: string
iconClassName?: string
}
export function SignalTriggerActions({ kind, signals, onChange, buttonClassName, iconClassName }: Props) {
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const accent = kind === 'entry' ? 'hover:text-accent hover:border-accent/40' : 'hover:text-warning hover:border-warning/40'
const btnCls = buttonClassName ?? 'rounded-btn border border-border bg-base p-1 text-muted transition-colors cursor-pointer'
const iconCls = iconClassName ?? 'h-3.5 w-3.5'
const handleSaved = (signal: CustomSignal) => {
if (signal.kind !== kind && signal.kind !== 'both') return
const signalId = `csg_${signal.id}`
onChange(signals.includes(signalId) ? signals : [...signals, signalId])
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
title="新增自定义信号"
className={`${btnCls} ${accent}`}
>
<Plus className={iconCls} />
</button>
<button
type="button"
onClick={() => navigate('/settings?tab=signals')}
title="去信号库"
className={`${btnCls} hover:border-amber-400/40 hover:text-amber-400`}
>
<Settings2 className={iconCls} />
</button>
<CustomSignalDialog
open={open}
defaultKind={kind}
onClose={() => setOpen(false)}
onSaved={handleSaved}
/>
</>
)
}
@@ -0,0 +1,81 @@
/** 迷你蜡烛图(自选/策略列表共享)。 */
import type { KlineRow } from '@/lib/api'
export function MiniCandlestick({ rows, width = 100, height = 80 }: { rows: KlineRow[]; width?: number; height?: number }) {
// 空数据:返回等尺寸占位(不画内容),保证 kline 加载前后单元格尺寸一致、不闪烁
if (!rows || rows.length === 0) {
return <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="block" aria-label="加载中" />
}
const BULL = '#C74040'
const BEAR = '#2D9B65'
const NEUTRAL = '#A1A1AA'
const W = width
const H = height
const padY = 2
const n = rows.length
const barW = W / n
const bodyW = Math.max(barW * 0.55, 2)
let hi = -Infinity, lo = Infinity
for (const r of rows) {
if (r.high > hi) hi = r.high
if (r.low < lo) lo = r.low
}
const range = hi - lo || 1
const yScale = (v: number) => padY + (1 - (v - lo) / range) * (H - padY * 2)
const rects: React.ReactNode[] = []
const wicks: React.ReactNode[] = []
for (let i = 0; i < n; i++) {
const r = rows[i]
const x = i * barW + barW / 2
// 涨跌判断: open !== close 用实体方向, 一字板用前日收盘计算涨跌
let color: string
if (r.close > r.open) {
color = BULL
} else if (r.close < r.open) {
color = BEAR
} else {
// 一字板: open === close, 用前一日收盘价判断涨跌方向
const prevClose = i > 0 ? rows[i - 1].close : null
if (prevClose && prevClose > 0 && r.close !== prevClose) {
color = r.close > prevClose ? BULL : BEAR
} else {
color = NEUTRAL
}
}
wicks.push(
<line
key={`w${i}`}
x1={x} y1={yScale(r.high)} x2={x} y2={yScale(r.low)}
stroke={color} strokeWidth={1}
/>
)
const top = yScale(Math.max(r.open, r.close))
const bot = yScale(Math.min(r.open, r.close))
const bodyH = Math.max(bot - top, 1)
rects.push(
<rect
key={`c${i}`}
x={x - bodyW / 2} y={top}
width={bodyW} height={bodyH}
fill={color}
/>
)
}
return (
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="block">
{wicks}
{rects}
</svg>
)
}
@@ -0,0 +1,120 @@
/**
* 股票列表表格骨架(自选/策略页共享)。
*
* 职责:表头渲染(读列配置 label/align + 可排序三态指示器)、表体遍历、sticky 表头。
* 不内置任何业务逻辑:单元格内容(含 symbol 列交互、操作列、ext 列)由调用方通过
* renderCell / renderExtraCol 注入。这样两个页面的特有交互得以保留,同时表头能力一致。
*/
import type { ReactNode } from 'react'
import type { ColumnConfig } from '@/lib/list-columns'
import { UNSORTABLE_KEYS } from '@/lib/stock-table'
import type { SortState } from './useTableSort'
export type { SortState }
export interface StockDataTableProps {
columns: ColumnConfig[]
rows: any[]
/** 单元格渲染回调。返回 null 时回退到内置纯数据列渲染。 */
renderCell: (r: any, col: ColumnConfig) => ReactNode
/** 行 key(默认取 r.symbol */
rowKey?: (r: any) => string | number
/** 行 className(默认含 hover */
rowClassName?: (r: any) => string
/** 表头是否 sticky(自选页需要,策略页不需要) */
headerSticky?: boolean
/** 最小表格宽度,默认按列数计算 */
minWidth?: number
/** 排序:外部受控时传入(含当前 sort 与 toggle);不传则表头不可排序 */
sort?: SortState | null
onSortToggle?: (colId: string) => void
/** 追加在每行末尾的额外单元格(如自选页的操作列) */
renderExtraCol?: (r: any) => ReactNode
/** 追加的表头单元格(对应 renderExtraCol */
extraHeader?: ReactNode
/** 自定义表头单元格内容覆盖(如日k眼睛按钮)。返回 undefined 则用 col.label */
renderHeaderContent?: (col: ColumnConfig) => ReactNode | undefined
/** 外层容器 className */
className?: string
}
function alignThClass(align: ColumnConfig['align']): string {
if (align === 'right') return 'px-3 py-2.5 font-medium text-right'
if (align === 'center') return 'px-3 py-2.5 font-medium text-center'
return 'px-3 py-2.5 font-medium'
}
export function StockDataTable({
columns, rows, renderCell,
rowKey = (r: any) => r.symbol,
rowClassName = () => 'border-t border-border hover:bg-elevated/50',
headerSticky = false,
minWidth,
sort,
onSortToggle,
renderExtraCol,
extraHeader,
renderHeaderContent,
className = 'rounded-card border border-border overflow-x-auto',
}: StockDataTableProps) {
const visibleColumns = columns.filter(c => c.visible)
const computedMinWidth = minWidth ?? Math.max(900, visibleColumns.length * 110)
const isColSortable = (col: ColumnConfig): boolean => {
// 排序能力由调用方是否提供 onSortToggle 决定;sort 是否为 null 只影响当前指示器
if (!onSortToggle) return false
if (col.source.type === 'builtin' && UNSORTABLE_KEYS.has(col.source.key)) return false
return true
}
const theadClass = headerSticky
? 'sticky top-0 z-10 bg-surface after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border'
: 'bg-elevated'
return (
<div className={className}>
<table className="w-full text-sm" style={{ minWidth: computedMinWidth }}>
<thead className={theadClass}>
<tr className="text-left text-secondary">
{visibleColumns.map(col => {
const sortable = isColSortable(col)
const isSorted = sort?.key === col.id
const dir = isSorted ? sort!.dir : null
const contentOverride = renderHeaderContent?.(col)
return (
<th
key={col.id}
className={`${alignThClass(col.align)} ${sortable ? 'cursor-pointer select-none group' : ''}`}
onClick={sortable ? () => onSortToggle!(col.id) : undefined}
>
{contentOverride !== undefined ? contentOverride : col.label}
{sortable && (
<span className="inline-block ml-1 text-[10px] opacity-30 group-hover:opacity-60 transition-opacity">
{isSorted ? (dir === 'asc' ? '↑' : '↓') : '↕'}
</span>
)}
</th>
)
})}
{extraHeader && (
<th className="px-3 py-2.5 font-medium text-right">{extraHeader}</th>
)}
</tr>
</thead>
<tbody>
{rows.map((r: any) => {
return (
<tr
key={rowKey(r)}
className={`transition-colors duration-150 ease-smooth group ${rowClassName(r)}`}
>
{visibleColumns.map(col => renderCell(r, col))}
{renderExtraCol && renderExtraCol(r)}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,163 @@
/**
* 股票列表单元格渲染原语(共享)。
*
* 只负责「无业务上下文」的纯数据列:价格/成交/均线/区间/技术指标/动量/连板/财务等。
* symbol、strategies、score、signals、candle 等需要页面上下文(加自选按钮、失效行、
* kline 数据、信号提取)的列由各页面的 renderCell 自行处理。
*
* 口径与原 ScreenerTable 对齐(已和自选页校准过):amplitude 用 *100、annual_vol/
* 财务率类用 fmtPct、kdj 用 toFixed(1)、vol_ma 用 fmtBigNum 等。
*/
import type { ReactNode } from 'react'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass } from '@/lib/format'
import type { ColumnConfig } from '@/lib/list-columns'
import { NUM_CELL_CLASS } from '@/lib/stock-table'
// ===== 板块标识(自选/策略页统一口径) =====
export function boardTag(symbol: string): { label: string; color: string } | null {
if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' }
if (/^688/.test(symbol)) return { label: '科', color: 'text-cyan-400 bg-cyan-400/12 border-cyan-400/25' }
if (/\.BJ$/.test(symbol)) return { label: '北', color: 'text-purple-400 bg-purple-400/12 border-purple-400/25' }
return null
}
// ===== RSI 指标带颜色 =====
export function RSIBadge({ value }: { value: number | null | undefined }) {
if (value == null || Number.isNaN(value)) return <span className="text-muted"></span>
let color = 'text-secondary'
if (value >= 80) color = 'text-danger font-medium'
else if (value >= 70) color = 'text-bull'
else if (value <= 20) color = 'text-bear font-medium'
else if (value <= 30) color = 'text-bear'
return <span className={`tabular-nums ${color}`}>{value.toFixed(1)}</span>
}
// ===== 纯数据列渲染 =====
function fmtMaybePrice(value: any) {
return value != null && !Number.isNaN(value) ? fmtPrice(value) : '—'
}
/**
* 渲染一个纯数据内置列的 <td>。
* 返回 null 表示该列不属于纯数据列(symbol/strategies/score/signals/candle 等),由调用方处理。
*/
export function renderBuiltinDataCell(r: any, col: ColumnConfig): ReactNode | null {
if (col.source.type !== 'builtin') return null
const key = col.source.key
// 这些列需要业务上下文,不在此处理
if (key === 'symbol' || key === 'strategies' || key === 'score' || key === 'signals' || key === 'candle') {
return null
}
const numCls = `${alignTdClass(col.align)} ${NUM_CELL_CLASS}`
switch (key) {
// 价格
case 'price':
return <td key={col.id} className={`${numCls} text-secondary`}>{fmtMaybePrice(r.close)}</td>
case 'pct':
return <td key={col.id} className={`${numCls} font-medium ${priceColorClass(r.change_pct)}`}>{fmtPct(r.change_pct)}</td>
case 'change_amount':
return <td key={col.id} className={`${numCls} ${priceColorClass(r.change_amount)}`}>{r.change_amount != null ? fmtPrice(r.change_amount) : '—'}</td>
case 'amplitude':
return <td key={col.id} className={numCls}>{r.amplitude != null ? `${(r.amplitude * 100).toFixed(2)}%` : '—'}</td>
// 成交
case 'turnover':
return <td key={col.id} className={numCls}>{r.turnover_rate != null ? `${Number(r.turnover_rate).toFixed(2)}%` : '—'}</td>
case 'amount':
return <td key={col.id} className={`${numCls} text-secondary`}>{fmtBigNum(r.amount)}</td>
case 'float_val':
return <td key={col.id} className={`${numCls} text-secondary`}>{r.float_shares && r.close ? fmtBigNum(r.float_shares * r.close) : '—'}</td>
case 'vol_ratio':
return (
<td key={col.id} className={numCls}>
<span className={r.vol_ratio_5d >= 2 ? 'text-accent font-medium' : ''}>
{fmtMaybePrice(r.vol_ratio_5d)}
</span>
</td>
)
case 'annual_vol':
return <td key={col.id} className={numCls}>{r.annual_vol_20d != null ? fmtPct(r.annual_vol_20d) : '—'}</td>
// 均线
case 'ma5': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.ma5)}</td>
case 'ma10': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.ma10)}</td>
case 'ma20': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.ma20)}</td>
case 'ma60': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.ma60)}</td>
// 区间
case 'high_60d': return <td key={col.id} className={numCls}>{r.high_60d != null ? fmtPrice(r.high_60d) : '—'}</td>
case 'low_60d': return <td key={col.id} className={numCls}>{r.low_60d != null ? fmtPrice(r.low_60d) : '—'}</td>
// 技术指标
case 'rsi6': return <td key={col.id} className={numCls}><RSIBadge value={r.rsi_6} /></td>
case 'rsi14': return <td key={col.id} className={numCls}><RSIBadge value={r.rsi_14} /></td>
case 'rsi24': return <td key={col.id} className={numCls}><RSIBadge value={r.rsi_24} /></td>
case 'macd_dif':
return <td key={col.id} className={`${numCls} ${priceColorClass(r.macd_hist)}`}>{r.macd_dif != null ? fmtPrice(r.macd_dif) : '—'}</td>
case 'macd_dea':
return <td key={col.id} className={numCls}>{r.macd_dea != null ? fmtPrice(r.macd_dea) : '—'}</td>
case 'macd_hist':
return <td key={col.id} className={`${numCls} ${priceColorClass(r.macd_hist)}`}>{r.macd_hist != null ? fmtPrice(r.macd_hist) : '—'}</td>
case 'kdj_k': return <td key={col.id} className={numCls}>{r.kdj_k != null ? r.kdj_k.toFixed(1) : '—'}</td>
case 'kdj_d': return <td key={col.id} className={numCls}>{r.kdj_d != null ? r.kdj_d.toFixed(1) : '—'}</td>
case 'kdj_j': return <td key={col.id} className={numCls}>{r.kdj_j != null ? r.kdj_j.toFixed(1) : '—'}</td>
case 'boll_upper': return <td key={col.id} className={numCls}>{r.boll_upper != null ? fmtPrice(r.boll_upper) : '—'}</td>
case 'boll_lower': return <td key={col.id} className={numCls}>{r.boll_lower != null ? fmtPrice(r.boll_lower) : '—'}</td>
case 'atr14': return <td key={col.id} className={numCls}>{r.atr_14 != null ? fmtPrice(r.atr_14) : '—'}</td>
case 'vol_ma5': return <td key={col.id} className={numCls}>{r.vol_ma5 != null ? fmtBigNum(r.vol_ma5) : '—'}</td>
case 'vol_ma10': return <td key={col.id} className={numCls}>{r.vol_ma10 != null ? fmtBigNum(r.vol_ma10) : '—'}</td>
// 动量
case 'momentum_5d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_5d)}`}>{fmtPct(r.momentum_5d)}</td>
case 'momentum_10d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_10d)}`}>{fmtPct(r.momentum_10d)}</td>
case 'momentum_20d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_20d)}`}>{fmtPct(r.momentum_20d)}</td>
case 'momentum_30d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_30d)}`}>{fmtPct(r.momentum_30d)}</td>
case 'momentum_60d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_60d)}`}>{fmtPct(r.momentum_60d)}</td>
// 连板
case 'limit_ups':
return (
<td key={col.id} className="px-3 py-2 text-center">
{r.consecutive_limit_ups > 0 ? (
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded bg-danger/15 text-danger text-xs font-bold tabular-nums">
{r.consecutive_limit_ups}
</span>
) : (
<span className="text-muted"></span>
)}
</td>
)
case 'limit_downs':
return (
<td key={col.id} className="px-3 py-2 text-center">
{r.consecutive_limit_downs > 0 ? (
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded bg-bear/15 text-bear text-xs font-bold tabular-nums">
{r.consecutive_limit_downs}
</span>
) : (
<span className="text-muted"></span>
)}
</td>
)
// 财务指标(后端 enriched 未返回时显示 —)
case 'eps': return <td key={col.id} className={numCls}>{r.eps != null ? fmtPrice(r.eps) : '—'}</td>
case 'bps': return <td key={col.id} className={numCls}>{r.bps != null ? fmtPrice(r.bps) : '—'}</td>
case 'roe': return <td key={col.id} className={numCls}>{r.roe != null ? fmtPct(r.roe) : '—'}</td>
case 'pe_ttm': return <td key={col.id} className={numCls}>{r.pe_ttm != null ? fmtPrice(r.pe_ttm) : '—'}</td>
case 'pb': return <td key={col.id} className={numCls}>{r.pb != null ? fmtPrice(r.pb) : '—'}</td>
case 'gross_margin': return <td key={col.id} className={numCls}>{r.gross_margin != null ? fmtPct(r.gross_margin) : '—'}</td>
case 'net_margin': return <td key={col.id} className={numCls}>{r.net_margin != null ? fmtPct(r.net_margin) : '—'}</td>
case 'revenue_yoy': return <td key={col.id} className={numCls}>{r.revenue_yoy != null ? fmtPct(r.revenue_yoy) : '—'}</td>
case 'net_income_yoy':return <td key={col.id} className={numCls}>{r.net_income_yoy != null ? fmtPct(r.net_income_yoy) : '—'}</td>
case 'debt_ratio': return <td key={col.id} className={numCls}>{r.debt_ratio != null ? fmtPct(r.debt_ratio) : '—'}</td>
default:
return <td key={col.id} className={`${alignTdClass(col.align)} text-muted`}></td>
}
}
/** 根据列对齐返回 td 基础 class(不含 num 样式) */
function alignTdClass(align: ColumnConfig['align']): string {
if (align === 'right') return 'px-3 py-2 text-right'
if (align === 'center') return 'px-3 py-2 text-center'
return 'px-3 py-2'
}
@@ -0,0 +1,50 @@
/**
* 表格排序 hook(三态:无 → 升序 → 降序 → 无)。
*
* 从自选页提炼,自选/策略列表共享。调用方提供 getSortValue 决定每列的排序标量。
*/
import { useCallback, useState } from 'react'
import type { ColumnConfig } from '@/lib/list-columns'
import { UNSORTABLE_KEYS, getSortValue as defaultGetSortValue } from '@/lib/stock-table'
export interface SortState {
key: string // 列 id
dir: 'asc' | 'desc'
}
export function useTableSort<T>(getSortValue: (r: T, col: ColumnConfig) => any = defaultGetSortValue) {
const [sort, setSort] = useState<SortState | null>(null)
/** 点击表头:同列轮换 asc→desc→清除;不同列重置为 asc */
const toggle = useCallback((colId: string) => {
setSort(prev => {
if (!prev || prev.key !== colId) return { key: colId, dir: 'asc' }
if (prev.dir === 'asc') return { key: colId, dir: 'desc' }
return null
})
}, [])
/** 对行集合按当前 sort 排序(返回新数组)。无 sort 或列为不可排序时原样返回。 */
const sortRows = useCallback((rows: T[], columns: ColumnConfig[]): T[] => {
if (!sort) return rows
const col = columns.find(c => c.id === sort.key)
if (!col) return rows
if (col.source.type === 'builtin' && UNSORTABLE_KEYS.has(col.source.key)) return rows
const { dir } = sort
return [...rows].sort((a, b) => {
const va = getSortValue(a, col)
const vb = getSortValue(b, col)
if (va == null) return 1
if (vb == null) return -1
const na = typeof va === 'number' ? va : Number(va)
const nb = typeof vb === 'number' ? vb : Number(vb)
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
return dir === 'asc' ? na - nb : nb - na
}
const sa = String(va), sb = String(vb)
return dir === 'asc' ? sa.localeCompare(sb) : sb.localeCompare(sa)
})
}, [sort, getSortValue])
return { sort, toggle, sortRows }
}
+79
View File
@@ -0,0 +1,79 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* === §6.0 设计语言 CSS variables ===
* 暗色为默认(html.dark) / 亮色用 :root 反转
* HSL 形式以兼容 Tailwind alpha-value 语法
*/
:root {
/* 亮色(可切换) */
--base: 0 0% 98%; /* #FAFAFA */
--surface: 0 0% 100%; /* #FFFFFF */
--elevated: 240 5% 96%; /* #F4F4F5 */
--border: 240 6% 90%; /* #E4E4E7 */
--fg-primary: 240 6% 10%; /* #18181B */
--fg-secondary: 240 4% 35%;/* #52525B */
--fg-muted: 240 5% 65%; /* #A1A1AA */
--accent: 217 91% 60%; /* #3B82F6 电光蓝 */
--bull: 4 87% 60%; /* #F04438 红涨 */
--bear: 152 67% 45%; /* #12B76A 绿跌 */
--warning: 32 95% 50%; /* #F79009 */
--danger: 4 87% 60%; /* #F04438 */
}
html.dark {
--base: 240 5% 5%; /* #0A0A0B */
--surface: 240 7% 10%; /* #18181B 略亮一点,与 base 对比清晰 */
--elevated: 240 9% 14%; /* #212126 */
--border: 240 5% 22%; /* #353539 边框加亮,易识别 */
--fg-primary: 0 0% 98%; /* #FAFAFA */
--fg-secondary: 240 5% 78%;/* #C4C4CB 大幅提亮 — 子标题/导航非激活态 */
--fg-muted: 240 5% 58%; /* #8E8E96 muted 提亮 — 辅助提示 */
--accent: 217 91% 60%;
--bull: 4 87% 60%;
--bear: 152 67% 45%;
--warning: 32 95% 50%;
--danger: 4 87% 60%;
}
/* 价格 / 数字一律等宽 */
.scrollbar-gutter-stable { scrollbar-gutter: stable; }
/* 全局滚动条 */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: hsl(var(--border) / 0.72);
border: 2px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: hsl(var(--accent) / 0.75);
background-clip: content-box;
}
.tabular { font-variant-numeric: tabular-nums; }
.num { font-family: theme('fontFamily.mono'); font-variant-numeric: tabular-nums; }
/* 默认 1px 细分隔线(暗色用边框区分层次,不用 shadow) */
* { border-color: hsl(var(--border)); }
body {
background: hsl(var(--base));
color: hsl(var(--fg-primary));
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
/* 暗色模式不用 antialiased — 让字形保持饱满,不发虚 */
-webkit-font-smoothing: subpixel-antialiased;
-moz-osx-font-smoothing: auto;
text-rendering: optimizeLegibility;
}
+341
View File
@@ -0,0 +1,341 @@
/**
* 概念/行业分析 — 数据适配层
*
* 处理两种扩展数据结构:
* - 结构 A(个股维度):每行一只股票,维度字段(如 concept)存该股票所属的概念/行业
* - 结构 B(板块维度):每行一个概念/行业,带成分股列表(如 constituents: [...]
*
* 两种结构统一输出为 DimensionGroup[],供页面组件消费。
*/
import type { ExtDataConfig, ExtDataField, ExtDataRowsResult } from '@/lib/api'
// ===== 公共类型 =====
export interface StockRow {
symbol: string
code?: string
name?: string
[key: string]: unknown
}
export interface DimensionGroup {
/** 维度名称(概念名/行业名) */
key: string
/** 成分股数量 */
count: number
/** 成分股原始行 */
stocks: StockRow[]
/** 聚合指标(如涨跌幅均值等) */
metrics: Record<string, number | null>
}
export interface ResolvedDimension {
/** 是否成功解析 */
ok: boolean
/** 数据结构类型 */
structure: 'per_stock' | 'per_dimension' | 'unknown'
/** 维度字段名 */
dimensionField: string
/** 所有解析出的分组 */
groups: DimensionGroup[]
/** 原始全部行(结构 A 下为原始行,结构 B 下展平后的全部成分股) */
allStocks: StockRow[]
/** 解析提示 */
hint?: string
}
// ===== 结构探测 =====
const SEPARATORS = /[、,;|/\s]+/
const CONSTITUENT_KEYS = [
'constituents', '成分股', 'stocks', 'members', 'codes', 'list',
'symbol_list', 'stock_list', 'member_list',
]
const DIMENSION_NAME_KEYS = [
'name', '概念名称', '概念', '行业名称', '行业', '板块名称', '板块',
'concept', 'industry', 'sector', 'theme', 'title', 'label',
]
/** 检测行是否是"板块维度"结构(含成分股列表字段) */
function detectConstituentField(fields: ExtDataField[]): string | null {
return fields.find(f =>
CONSTITUENT_KEYS.some(k => f.name.toLowerCase() === k.toLowerCase() || f.label?.includes(k))
)?.name ?? null
}
/** 检测维度名称字段 */
function detectDimensionNameField(fields: ExtDataField[]): string | null {
return fields.find(f =>
DIMENSION_NAME_KEYS.some(k => f.name.toLowerCase() === k.toLowerCase() || f.label?.includes(k))
)?.name ?? null
}
/** 从候选名中选取最佳维度字段(结构 A) */
export function pickDimensionField(
fields: ExtDataField[],
candidates: string[],
): string {
const nonMeta = fields.filter(f =>
!['symbol', 'code', 'name', '股票简称', '股票代码', 'date'].includes(f.name)
)
for (const c of candidates) {
const m = nonMeta.find(f =>
f.name.toLowerCase().includes(c.toLowerCase()) ||
f.label?.toLowerCase().includes(c.toLowerCase())
)
if (m) return m.name
}
// 回退:第一个非数值字段
return nonMeta.find(f => f.dtype !== 'int' && f.dtype !== 'float')?.name ?? nonMeta[0]?.name ?? ''
}
/** 判断字段是否为数值类型 */
function isNumericField(f: ExtDataField): boolean {
return f.dtype === 'int' || f.dtype === 'float'
}
// ===== 结构 A 解析:个股维度 =====
function parsePerStock(
rows: Record<string, any>[],
dimensionField: string,
numericFields: string[],
): DimensionGroup[] {
const map = new Map<string, StockRow[]>()
for (const row of rows) {
const raw = row[dimensionField]
if (raw == null) continue
const text = String(raw).trim()
if (!text) continue
// 支持多值分隔(如 "人工智能,芯片,5G"
const values = text.split(SEPARATORS).map(s => s.trim()).filter(Boolean)
const stock: StockRow = { ...row, symbol: row.symbol ?? row.code ?? '' }
for (const v of values) {
const list = map.get(v) ?? []
list.push(stock)
map.set(v, list)
}
}
return [...map.entries()]
.map(([key, stocks]) => ({
key,
count: stocks.length,
stocks,
metrics: computeMetrics(stocks, numericFields),
}))
.sort((a, b) => b.count - a.count)
}
// ===== 结构 B 解析:板块维度 =====
function parsePerDimension(
rows: Record<string, any>[],
constituentField: string,
nameField: string,
numericFields: string[],
): DimensionGroup[] {
const allStocks: StockRow[] = []
const groups = rows.map(row => {
const key = String(row[nameField] ?? row[constituentField] ?? '').trim()
if (!key) return null
// 成分股可能是字符串数组、对象数组、逗号分隔字符串
const rawList = row[constituentField]
const stocks = parseConstituents(rawList)
stocks.forEach(s => { if (s.symbol) allStocks.push(s) })
// 维度自身的数值指标也保留
const metrics = computeMetrics(stocks, numericFields)
// 补上行级别的数值
for (const f of numericFields) {
if (typeof row[f] === 'number') {
metrics[`__dim_${f}`] = row[f]
}
}
return { key, count: stocks.length, stocks, metrics } as DimensionGroup
}).filter((g): g is DimensionGroup => g !== null && g.key !== '')
return groups.sort((a, b) => b.count - a.count)
}
/** 解析成分股字段(支持多种格式) */
function parseConstituents(raw: unknown): StockRow[] {
if (raw == null) return []
if (typeof raw === 'string') {
// 逗号/分隔符分隔的股票代码字符串
return raw.split(SEPARATORS).map(s => s.trim()).filter(Boolean).map(s => ({
symbol: normalizeSymbol(s),
code: s,
}))
}
if (Array.isArray(raw)) {
return raw.map(item => {
if (typeof item === 'string') {
return { symbol: normalizeSymbol(item), code: item }
}
if (typeof item === 'object' && item !== null) {
const obj = item as Record<string, any>
return {
symbol: obj.symbol ?? obj.code ?? obj. ?? '',
code: obj.code ?? obj.symbol ?? '',
name: obj.name ?? obj. ?? obj. ?? '',
...obj,
}
}
return { symbol: String(item) }
})
}
return []
}
function normalizeSymbol(s: string): string {
// 尝试补全为 6 位代码
if (/^\d{6}$/.test(s)) return s
return s
}
// ===== 聚合指标计算 =====
function computeMetrics(
stocks: StockRow[],
numericFields: string[],
): Record<string, number | null> {
const result: Record<string, number | null> = {}
for (const f of numericFields) {
const vals = stocks
.map(s => s[f])
.filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
if (vals.length === 0) {
result[f] = null
} else {
result[f] = vals.reduce((a, b) => a + b, 0) / vals.length
}
}
return result
}
// ===== 主入口:自动探测 + 解析 =====
export function resolveDimension(
data: ExtDataRowsResult | null | undefined,
config: ExtDataConfig | null | undefined,
candidateFields: string[],
): ResolvedDimension {
if (!data || !config || !data.rows.length) {
return { ok: false, structure: 'unknown', dimensionField: '', groups: [], allStocks: [] }
}
const fields = data.fields ?? config.fields
const rows = data.rows
const numericFields = fields.filter(f => isNumericField(f)).map(f => f.name)
// 先检测是否为结构 B(板块维度)
const constituentField = detectConstituentField(fields)
if (constituentField) {
const nameField = detectDimensionNameField(fields) ?? 'name'
const groups = parsePerDimension(rows, constituentField, nameField, numericFields)
const allStocks = groups.flatMap(g => g.stocks)
return {
ok: true,
structure: 'per_dimension',
dimensionField: nameField,
groups,
allStocks,
hint: `检测到板块维度结构(成分股字段: ${constituentField}`,
}
}
// 结构 A(个股维度)
const dimensionField = pickDimensionField(fields, candidateFields)
if (!dimensionField) {
return {
ok: false,
structure: 'unknown',
dimensionField: '',
groups: [],
allStocks: rows as StockRow[],
hint: '未找到合适的维度字段',
}
}
const groups = parsePerStock(rows, dimensionField, numericFields)
const allStocks = rows as StockRow[]
return {
ok: true,
structure: 'per_stock',
dimensionField,
groups,
allStocks,
hint: `${dimensionField} 分组,共 ${groups.length} 个维度`,
}
}
// ===== 行情数据关联 =====
export interface QuoteMap {
symbol: string
price?: number
pct?: number
change_pct?: number
name?: string
[key: string]: unknown
}
/** 构建 symbol → quote 的快速查找 */
export function buildQuoteMap(quotes: QuoteMap[]): Map<string, QuoteMap> {
const map = new Map<string, QuoteMap>()
for (const q of quotes) {
if (q.symbol) map.set(q.symbol, q)
// 也用纯数字代码做索引
const code = q.symbol?.replace(/\.\w+$/, '')
if (code) map.set(code, q)
}
return map
}
/** 为分组计算行情聚合指标 */
export function computeQuoteMetrics(
stocks: StockRow[],
quoteMap: Map<string, QuoteMap>,
): {
avgPct: number | null
upCount: number
downCount: number
flatCount: number
totalVolume: number
} {
let up = 0, down = 0, flat = 0, totalVol = 0
let sumPct = 0, countPct = 0
for (const s of stocks) {
const sym = String(s.symbol ?? '')
const q = quoteMap.get(sym) ?? quoteMap.get(sym.replace(/\.\w+$/, ''))
if (!q) continue
const pct = q.pct ?? q.change_pct
if (pct != null && typeof pct === 'number' && Number.isFinite(pct)) {
sumPct += pct
countPct++
if (pct > 0) up++
else if (pct < 0) down++
else flat++
}
totalVol += (typeof q.price === 'number' ? 1 : 0) // 简化计数
}
return {
avgPct: countPct > 0 ? sumPct / countPct : null,
upCount: up,
downCount: down,
flatCount: flat,
totalVolume: totalVol,
}
}
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
import { useSyncExternalStore } from 'react'
import type { StrategyBacktestResult } from './api'
/**
* 全局回测任务管理 (SSE 模式 + 任务缓存 + 重连支持)。
*
* 特性:
* - 实时进度: EventSource 监听后端 SSE, 推送 day/total/equity
* - 可取消: POST /strategy/cancel/{job_key}, 后端 cancel_event
* - 切页/刷新保持: 后端按参数 hash 缓存任务, 重连不重启
* - 切页: 模块级 store 保持, EventSource 随组件卸载断开, 回来后重连
* - 刷新: localStorage 存 job 参数, 刷新后重新连接到同一任务
*/
export interface BacktestProgress {
day: number
total: number
date: string
equity: number
}
export interface BacktestTask {
id: number
isPending: boolean
result: StrategyBacktestResult | null
progress: BacktestProgress | null
error: string | null
}
let current: BacktestTask | null = null
const listeners = new Set<() => void>()
let taskSeq = 0
let eventSource: EventSource | null = null
const RECONNECT_KEY = 'backtest_reconnect'
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function getSnapshot() {
return current
}
function getServerSnapshot() {
return null
}
/** 查询字符串构建 */
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') sp.set(k, String(v))
}
return sp.toString()
}
/** 连接 SSE (新建或重连都用这个) */
function connectSSE(url: string): void {
const id = current?.id ?? ++taskSeq
// 关闭旧连接
if (eventSource) {
eventSource.close()
eventSource = null
}
const es = new EventSource(url)
eventSource = es
es.addEventListener('progress', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const prog = JSON.parse(e.data) as BacktestProgress
current = { ...current, progress: prog }
emit()
} catch { /* ignore */ }
})
es.addEventListener('done', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const result = JSON.parse(e.data) as StrategyBacktestResult
current = { ...current, isPending: false, result, error: null }
emit()
} catch {
current = { ...current, isPending: false, error: '结果解析失败' }
emit()
}
es.close()
eventSource = null
localStorage.removeItem(RECONNECT_KEY)
})
es.addEventListener('error', (e: MessageEvent) => {
if (current?.id !== id) return
// SSE error 事件: 有 data 说明是后端主动推送的错误/取消; 无 data 说明是连接断开
if (e.data) {
try {
const msg = JSON.parse(e.data)?.message ?? '回测出错'
current = { ...current, isPending: false, error: msg }
emit()
} catch {
current = { ...current, isPending: false, error: '回测出错' }
emit()
}
es.close()
eventSource = null
localStorage.removeItem(RECONNECT_KEY)
}
// 无 data: 连接异常断开, EventSource 会自动重连, 不改变状态
})
}
/** 启动一次 SSE 回测任务 */
export function startBacktest(params: {
strategy_id: string
symbols?: string[] | null
start?: string | null
end?: string | null
matching?: string
entry_fill?: string
exit_fill?: string
fees_pct?: number
slippage_bps?: number
max_positions?: number
max_exposure_pct?: number
initial_capital?: number
position_sizing?: string
params?: Record<string, any> | null
overrides?: Record<string, any> | null
mode?: 'position' | 'full'
holding_days?: number
}): void {
// 取消之前的任务状态
if (eventSource) {
eventSource.close()
eventSource = null
}
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
const qs = buildQuery({
strategy_id: params.strategy_id,
symbols: params.symbols?.join(','),
start: params.start ?? undefined,
end: params.end ?? undefined,
matching: params.matching,
entry_fill: params.entry_fill,
exit_fill: params.exit_fill,
fees_pct: params.fees_pct,
slippage_bps: params.slippage_bps,
max_positions: params.max_positions,
max_exposure_pct: params.max_exposure_pct,
initial_capital: params.initial_capital,
position_sizing: params.position_sizing,
params: params.params ? JSON.stringify(params.params) : undefined,
overrides: params.overrides ? JSON.stringify(params.overrides) : undefined,
mode: params.mode,
holding_days: params.holding_days,
})
// 存 reconnect 信息 (刷新后用)
localStorage.setItem(RECONNECT_KEY, qs)
connectSSE(`/api/backtest/strategy/stream?${qs}`)
}
/** 停止当前回测任务 (调后端 cancel, 后端 cancel_event → 停止计算) */
export async function stopBacktest(): Promise<void> {
// 从 reconnect key 提取 job_key (后端按参数 hash 算 job_key)
const qs = localStorage.getItem(RECONNECT_KEY)
if (qs) {
// 解析出参数, 用 fetch 调 cancel
try {
// job_key 是后端算的 md5, 前端不知道。用 reconnect URL 里的参数重新请求 stream,
// 后端会找到同一个 job 并返回它的 job_key? 不行。
// 替代: 前端直接关闭 SSE 连接 + 调一个带参数的 cancel 接口。
// 简化: 关闭连接即可, 后端检测断开后 (不取消)。需要 cancel 用 POST。
// 这里用 cancel 接口: POST /strategy/cancel, body 带 qs 的参数。
await fetch('/api/backtest/strategy/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qs }),
}).catch(() => {})
} catch { /* ignore */ }
}
if (eventSource) {
eventSource.close()
eventSource = null
}
if (current?.isPending) {
current = { ...current, isPending: false, error: '已取消' }
emit()
}
localStorage.removeItem(RECONNECT_KEY)
}
/** 清除任务状态 (隐藏提示) */
export function clearBacktest(): void {
current = null
emit()
}
/** 恢复: 从 localStorage 读取 reconnect 信息, 重新连接 (刷新后调用) */
export function tryReconnect(): boolean {
const qs = localStorage.getItem(RECONNECT_KEY)
if (!qs) return false
// 有未完成的任务, 重连
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
connectSSE(`/api/backtest/strategy/stream?${qs}`)
return true
}
/** React hook: 读取当前全局回测任务状态 */
export function useBacktestTask(): BacktestTask | null {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
+25
View File
@@ -0,0 +1,25 @@
// 板块判断工具函数
export const BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] as const
export type BoardType = (typeof BOARDS)[number]
/** 根据股票代码判断板块 */
export function getBoardType(symbol: string): BoardType | null {
if (/^(300|301)/.test(symbol)) return '创业板'
if (/^688/.test(symbol)) return '科创板'
if (/\.BJ$/.test(symbol)) return '北交所'
if (/^60[0135]/.test(symbol)) return '沪主板'
if (/^00[012]/.test(symbol)) return '深主板'
return null
}
/** 板块简称标签: 主板返回空字符串(不显示), 创/科/北 等返回简称 */
export function boardTag(symbol: string): string {
const b = getBoardType(symbol)
if (!b) return ''
if (b === '沪主板' || b === '深主板') return ''
if (b === '创业板') return '创'
if (b === '科创板') return '科'
if (b === '北交所') return '北'
return ''
}
+106
View File
@@ -0,0 +1,106 @@
// capability 内部名 → 用户能理解的中文标签
export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'quote.by_symbol': { name: '实时行情(按标的)', hint: '查询单只股票当前价' },
'quote.batch': { name: '实时行情(批量)', hint: '一次拿多只股票的价' },
'quote.pool': { name: '标的池查询', hint: '按沪深300等池子拿行情' },
'kline.daily.by_symbol': { name: '日 K(按标的)', hint: '单只股票历史日 K' },
'kline.daily.batch': { name: '日 K(批量)', hint: '一次拿多只股票的日 K — 选股 / 信号扫描 必需' },
'kline.minute.by_symbol': { name: '分钟 K(按标的)', hint: '单股 1m/5m/15m/30m/60m K 线' },
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
}
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
// 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
export function tierRank(label: string): number {
const base = (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase()
return TIER_RANK[base] ?? -1
}
export function isExpertOrAbove(label: string): boolean {
return tierRank(label) >= EXPERT_RANK
}
/** 档位完整样式(tag 背景 + 圆点 + 文字渐变), 与左侧菜单 TierBadge 一致 */
export interface TierStyle {
tagBg: { background: string }
dotStyle: { background: string }
labelTextStyle: { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string }
desc: string
}
const TIER_STYLE: Record<string, TierStyle> = {
none: {
desc: '未配置 Key · 仅历史日K',
tagBg: { background: 'rgba(113,113,122,0.15)' },
dotStyle: { background: '#52525b' },
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '基础日K · 单股查询',
tagBg: { background: 'rgba(113,113,122,0.3)' },
dotStyle: { background: '#71717a' },
labelTextStyle: { color: '#a1a1aa' },
},
starter: {
desc: '批量同步 · 行情池',
tagBg: { background: 'rgba(59,130,246,0.2)' },
dotStyle: { background: '#3b82f6' },
labelTextStyle: { color: '#60a5fa' },
},
pro: {
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' },
},
expert: {
desc: 'WebSocket · 财务数据',
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
}
/** 从档位 label 提取基础档位名(小写): "Expert +" → "expert" */
export function tierBaseName(label: string): string {
return (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase()
}
/** 返回档位完整样式 */
export function tierStyle(label: string): TierStyle {
return TIER_STYLE[tierBaseName(label)] ?? TIER_STYLE.free
}
/** 所有档位(有序, 供档位列表渲染) */
export const ALL_TIERS = ['none', 'free', 'starter', 'pro', 'expert'] as const
/** 返回档位标签的渐变文字样式(用于大字显示, 如 Keys 页档位) */
export function tierTextStyle(label: string): { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string } {
return tierStyle(label).labelTextStyle
}
/** 渲染档位 tag(与左侧菜单一致的胶囊样式) */
export function TierTag({ label, className = '' }: { label: string; className?: string }) {
const t = tierStyle(label)
const base = tierBaseName(label)
// none 档显示中文「无」,其余档显示英文档名
const display = base === '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}`}
style={t.tagBg}
>
<span className="truncate capitalize" style={t.labelTextStyle}>{display}</span>
</span>
)
}
+68
View File
@@ -0,0 +1,68 @@
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])
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+31
View File
@@ -0,0 +1,31 @@
/**
* 全局颜色别名 — 集中管理项目中重复使用的色彩组合。
*
* 不修改 Tailwind 配置和 CSS 变量,仅作为语义化引用。
* 使用方式:
* import { color } from '@/lib/colors'
* className={`${color.select.bg} ${color.select.text}`}
* // → bg-sky-500 text-sky-400
*/
export const color = {
/** 选中/激活态 — sky 色系 */
select: {
bg: 'bg-sky-500',
text: 'text-sky-400',
border: 'border-sky-400/40',
bgLight: 'bg-sky-400/10',
borderHover: 'hover:border-sky-400/30',
},
/** 选股条件区 section 标题色 */
filterSection: 'text-sky-400',
/** 评分权重正常指示 — emerald 色系 */
ok: 'text-emerald-400',
/** 评分权重异常警告 — amber 色系 */
scoreWarn: 'text-amber-400',
/** 交易参数区 section 标题色 */
tradeSection: 'text-emerald-400',
} as const
+81
View File
@@ -0,0 +1,81 @@
// 数字 / 价格 / 涨跌幅 格式化(§6.0.2 等宽数字)
export function fmtPrice(v: number | null | undefined, digits = 2): string {
if (v == null || Number.isNaN(v)) return '—'
return v.toFixed(digits)
}
export function fmtPct(v: number | null | undefined, digits = 2): string {
if (v == null || Number.isNaN(v)) return '—'
const sign = v > 0 ? '+' : ''
return `${sign}${(v * 100).toFixed(digits)}%`
}
export function fmtVolume(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
if (v >= 1e8) return `${(v / 1e8).toFixed(2)}亿`
if (v >= 1e4) return `${(v / 1e4).toFixed(2)}`
return v.toFixed(0)
}
// A 股语义色:红涨绿跌 → 仅用于价格相关元素
export function priceColorClass(v: number | null | undefined): string {
if (v == null || Number.isNaN(v) || v === 0) return 'text-muted'
return v > 0 ? 'text-bull' : 'text-bear'
}
export function fmtBigNum(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
if (v >= 1_000_000_000_000) return `${(v / 1_000_000_000_000).toFixed(2)}万亿`
if (v >= 100_000_000) return `${(v / 100_000_000).toFixed(2)}亿`
if (v >= 10_000) return `${(v / 10_000).toFixed(0)}`
return v.toFixed(0)
}
export function fmtDate(s: string | Date | null | undefined): string {
if (s == null) return '—'
const d = typeof s === 'string' ? new Date(s) : s
if (isNaN(d.getTime())) return String(s)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// ===== Data 页面工具函数 =====
export function formatNumber(n: number): string {
if (n >= 100_000_000) return `${(n / 100_000_000).toFixed(1)}亿`
if (n >= 10_000) return `${(n / 10_000).toFixed(1)}`
return n.toLocaleString()
}
export function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`
const m = Math.floor(seconds / 60)
const s = seconds % 60
if (m < 60) return s > 0 ? `${m}m ${s}s` : `${m}m`
const h = Math.floor(m / 60)
const rm = m % 60
return rm > 0 ? `${h}h ${rm}m` : `${h}h`
}
export function formatScheduleDatePart(iso: string): string {
const d = new Date(iso)
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function formatScheduleTimePart(iso: string): string {
const d = new Date(iso)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export function isToday(iso: string): boolean {
const d = new Date(iso)
const now = new Date()
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate()
}
export function formatLogTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
}
+199
View File
@@ -0,0 +1,199 @@
/**
* 通用列表列配置底座。
*
* 业务页面只负责定义内置列、分组和持久化 adapter;拖拽、显隐、扩展列参数等
* 公共能力集中在这里,避免每个股票列表重复实现。
*/
export type ColumnSource =
| { type: 'builtin'; key: string }
| { type: 'ext'; configId: string; fieldName: string; fieldLabel?: string }
| { type: 'computed'; key: string }
/** 扩展列字符串值渲染配置 */
export interface ExtColumnDisplayConfig {
/** 显示模式: tag=分隔为标签, text=纯文本 */
displayMode: 'tag' | 'text'
/** 自定义分隔符,留空使用默认 [、,,;;-] */
separator?: string
/** 列最大宽度 CSS 值,如 "200px" */
maxWidth?: string
/** 标签显示上限,0 或 undefined=全部显示 */
maxTags?: number
/** 隐藏的标签索引(0-based),在 maxTags 范围内按位置隐藏 */
hiddenIndices?: number[]
/** 标签排列方向: horizontal=横向(默认), vertical=竖向 */
tagLayout?: 'horizontal' | 'vertical'
}
/** 日k列渲染配置(builtin: candle 列专用) */
export interface CandleColumnConfig {
/** 开启(显示蜡烛图)时单元格宽度 px */
enabledWidth?: number
/** 开启时单元格高度 px */
enabledHeight?: number
/** 关闭(收起)时单元格宽度 px */
disabledWidth?: number
/** 关闭时单元格高度 px */
disabledHeight?: number
/** 显示最近多少个交易日的日k */
days?: number
}
/** 日k列配置默认值(与改造前 MiniCandlestick 硬编码值一致) */
export const DEFAULT_CANDLE_CONFIG: Required<CandleColumnConfig> = {
enabledWidth: 100,
enabledHeight: 80,
disabledWidth: 40,
disabledHeight: 40,
days: 12,
}
/** 数值边界(设置过大取上限,过小取最小值) */
const CANDLE_BOUNDS = {
enabledWidth: { min: 40, max: 300 },
enabledHeight: { min: 32, max: 200 },
disabledWidth: { min: 20, max: 200 },
disabledHeight:{ min: 20, max: 200 },
days: { min: 1, max: 60 },
} as const
function clampNum(v: unknown, bounds: { min: number; max: number }, fallback: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? v : fallback
return Math.min(bounds.max, Math.max(bounds.min, n))
}
/**
* 合并用户配置与默认值,并对越界数值做钳制(过大取上限,过小取最小值)。
* 返回字段齐全的配置,调用方可直接解构使用。
*/
export function resolveCandleConfig(cfg: CandleColumnConfig | undefined): Required<CandleColumnConfig> {
const c = cfg ?? {}
return {
enabledWidth: clampNum(c.enabledWidth, CANDLE_BOUNDS.enabledWidth, DEFAULT_CANDLE_CONFIG.enabledWidth),
enabledHeight: clampNum(c.enabledHeight, CANDLE_BOUNDS.enabledHeight, DEFAULT_CANDLE_CONFIG.enabledHeight),
disabledWidth: clampNum(c.disabledWidth, CANDLE_BOUNDS.disabledWidth, DEFAULT_CANDLE_CONFIG.disabledWidth),
disabledHeight: clampNum(c.disabledHeight, CANDLE_BOUNDS.disabledHeight, DEFAULT_CANDLE_CONFIG.disabledHeight),
days: clampNum(c.days, CANDLE_BOUNDS.days, DEFAULT_CANDLE_CONFIG.days),
}
}
export interface ColumnConfig {
id: string // 唯一标识,如 "builtin:price" 或 "ext:my_table:score"
source: ColumnSource
label: string // 用户看到的表头名
visible: boolean // 是否显示
pinned?: boolean // 固定列不可隐藏(代码/名称、操作)
align?: 'left' | 'center' | 'right'
/** 扩展列显示配置(仅 ext 类型生效) */
extDisplay?: ExtColumnDisplayConfig
/** 日k列渲染配置(仅 builtin: candle 列生效) */
candleConfig?: CandleColumnConfig
/** 信息条场景:是否单独占一行显示(仅 StockInfoBar 生效,表格场景忽略) */
standalone?: boolean
}
export interface ColumnGroup {
id: string
label: string
icon?: string
/** builtin/computed source key 列表 */
keys: string[]
}
export const DEFAULT_ACTION_COLUMN_ID = 'builtin:action'
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action */
export function serializeColumns(
columns: ColumnConfig[],
actionColumnId = DEFAULT_ACTION_COLUMN_ID,
): ColumnConfig[] {
return columns.filter(c => !c.pinned && c.id !== actionColumnId)
}
export interface MergeColumnsOptions {
actionColumnId?: string
pinnedFirstIds?: string[]
}
/** 合并用户保存的列与默认列,保留用户顺序并补齐新增默认列。 */
export function mergeColumns(
saved: ColumnConfig[] | null | undefined,
defaults: ColumnConfig[],
options: MergeColumnsOptions = {},
): ColumnConfig[] {
const actionColumnId = options.actionColumnId ?? DEFAULT_ACTION_COLUMN_ID
const pinnedFirstIds = options.pinnedFirstIds ?? ['builtin:symbol']
const normalizedSaved = Array.isArray(saved) ? saved : []
const result: ColumnConfig[] = []
const savedMap = new Map(normalizedSaved.map(c => [c.id, c]))
const defaultMap = new Map(defaults.map(c => [c.id, c]))
// 1. 按用户保存顺序排列
for (const col of normalizedSaved) {
if (!col || col.id === actionColumnId) continue
const def = defaultMap.get(col.id)
if (def) {
// 内置列: label/source/align/pinned 以默认定义为准;visible 使用用户配置;
// 用户自定义的渲染配置(如日k的 candleConfig、策略列的 extDisplay、信息条 standalone)需保留,否则刷新后丢失
result.push({
...def,
visible: col.visible,
...(col.candleConfig ? { candleConfig: col.candleConfig } : {}),
...(col.extDisplay ? { extDisplay: col.extDisplay } : {}),
...(col.standalone ? { standalone: col.standalone } : {}),
})
} else if (col.source?.type === 'ext') {
// ext 列: 保留用户配置,清理旧 label 中的括号后缀
let extCol = col
if (col.label.includes('(') || col.label.includes('')) {
extCol = {
...col,
label: col.source.fieldLabel || col.label.replace(/[(].*/, '').trim() || col.source.fieldName,
}
}
result.push(extCol)
}
}
// 2. 补充新增的默认列
for (const def of defaults) {
if (!savedMap.has(def.id)) result.push(def)
}
// 3. 固定优先列放到最前,例如代码/名称
for (let i = pinnedFirstIds.length - 1; i >= 0; i -= 1) {
const id = pinnedFirstIds[i]
const idx = result.findIndex(c => c.id === id)
if (idx > 0) {
const [col] = result.splice(idx, 1)
result.unshift(col)
}
}
return result
}
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口。 */
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
return columns
.filter(c => c.visible && c.source.type === 'ext')
.map(c => `${(c.source as { type: 'ext'; configId: string; fieldName: string }).configId}.${(c.source as { type: 'ext'; configId: string; fieldName: string }).fieldName}`)
.join(',')
}
/** 根据 ext schema 数据创建 ext 列配置。 */
export function createExtColumn(
configId: string,
_configLabel: string,
fieldName: string,
fieldLabel?: string,
): ColumnConfig {
return {
id: `ext:${configId}:${fieldName}`,
source: { type: 'ext', configId, fieldName, fieldLabel },
label: fieldLabel || fieldName,
visible: false,
align: 'center',
}
}
+116
View File
@@ -0,0 +1,116 @@
import { useSyncExternalStore } from 'react'
/**
* 监控中心未读触发记录徽标 — 全局 store + localStorage 持久化。
*
* 核心逻辑:
* - 在监控中心页面时, 每次收到新推送都同步更新 lastSeen (看到=已读)
* - 离开监控中心后, 新推送才计入未读
* - 刷新页面从 localStorage 恢复 lastSeen, 未读 = 期间新增
*/
const STORAGE_KEY = 'monitor_last_seen_total'
let currentTotal = 0
let lastSeenTotal = readSeen()
let onMonitorPage = false // 当前是否在监控中心页面
let pendingSeen = false // Monitor mount 请求 markSeen, 等 currentTotal 就绪
const listeners = new Set<() => void>()
function readSeen(): number {
try {
const v = localStorage.getItem(STORAGE_KEY)
return v ? parseInt(v, 10) || 0 : -1 // -1 = 未初始化
} catch {
return -1
}
}
function writeSeen(v: number) {
try { localStorage.setItem(STORAGE_KEY, String(v)) } catch { /* ignore */ }
}
function syncSeen() {
if (lastSeenTotal !== currentTotal) {
lastSeenTotal = currentTotal
writeSeen(currentTotal)
}
}
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => { listeners.delete(fn) }
}
function getSnapshot() {
return Math.max(0, currentTotal - lastSeenTotal)
}
/** 轮询更新最新总数 (Layout 层调用)。 */
export function setCurrentTotal(total: number): void {
// undefined/null 视为未就绪, 不更新 (Layout 层已在传入前过滤)
if (total == null) return
// 首次初始化: 把已读基线设为当前总数 (包括 total=0, 避免未初始化时徽章 stuck 在 1)
if (lastSeenTotal < 0) {
lastSeenTotal = total
writeSeen(total)
}
// 总数减少 (清空) → 同步重置
if (total < lastSeenTotal) {
lastSeenTotal = total
writeSeen(total)
}
const changed = total !== currentTotal
currentTotal = total
// 消费 pending markSeen
if (pendingSeen) {
pendingSeen = false
syncSeen()
}
// ★ 在监控中心页面期间: 收到新推送立即同步 (看到=已读, 不计入未读)
else if (onMonitorPage && changed) {
syncSeen()
}
emit()
}
/** 进入监控页时调用。 */
export function markSeen(): void {
onMonitorPage = true
if (currentTotal > 0) {
syncSeen()
emit()
} else {
pendingSeen = true
}
}
/** 离开监控页时调用 (停止同步, 之后新增才计入未读)。 */
export function leaveMonitorPage(): void {
onMonitorPage = false
pendingSeen = false
// 不在此处 syncSeen — lastSeen 保持页面期间最后一次同步的值即可
// (避免 currentTotal 此刻还没刷新到最新, 写入偏小的值)
}
/** 记录被清空时调用。 */
export function resetBadge(): void {
currentTotal = 0
lastSeenTotal = 0
pendingSeen = false
writeSeen(0)
emit()
}
/** 读取当前未读数。 */
export function useUnreadAlerts(): number {
return useSyncExternalStore(subscribe, getSnapshot, () => 0)
}
+154
View File
@@ -0,0 +1,154 @@
/**
* 通知声效 — 用 Web Audio API 合成, 无需音频文件。
*
* 声效列表 (纯代码合成, 不同频率/波形/节奏):
* - ding: 清脆"叮" (默认, 适合一般提醒)
* - chime: 两音阶风铃 (适合策略信号)
* - alert: 急促警报 (适合重要/异动)
* - soft: 柔和低音 (适合价格提醒)
* - none: 无声
*/
let _audioCtx: AudioContext | null = null
function getCtx(): AudioContext | null {
try {
if (!_audioCtx) {
_audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
}
if (_audioCtx.state === 'suspended') _audioCtx.resume()
return _audioCtx
} catch {
return null
}
}
/** 播放单个音符 */
function playTone(ctx: AudioContext, freq: number, start: number, duration: number, type: OscillatorType = 'sine', gain: number = 0.15) {
const osc = ctx.createOscillator()
const g = ctx.createGain()
osc.type = type
osc.frequency.value = freq
osc.connect(g)
g.connect(ctx.destination)
const t = ctx.currentTime + start
g.gain.setValueAtTime(0, t)
g.gain.linearRampToValueAtTime(gain, t + 0.01)
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
osc.start(t)
osc.stop(t + duration + 0.05)
}
const SOUND_PRESETS: Record<string, (ctx: AudioContext) => void> = {
// 清脆"叮" — 一个正弦波短音
ding: (ctx) => {
playTone(ctx, 880, 0, 0.3, 'sine', 0.2)
},
// 两音阶风铃 — 高低两个音
chime: (ctx) => {
playTone(ctx, 660, 0, 0.25, 'sine', 0.15)
playTone(ctx, 990, 0.12, 0.35, 'sine', 0.15)
},
// 急促警报 — 三个快速方波
alert: (ctx) => {
playTone(ctx, 800, 0, 0.1, 'square', 0.12)
playTone(ctx, 800, 0.15, 0.1, 'square', 0.12)
playTone(ctx, 1000, 0.3, 0.15, 'square', 0.12)
},
// 柔和低音 — 低频三角波
soft: (ctx) => {
playTone(ctx, 440, 0, 0.5, 'triangle', 0.15)
playTone(ctx, 330, 0.2, 0.5, 'triangle', 0.12)
},
// 上升音阶 — C-E-G-C 递进 (积极感)
rise: (ctx) => {
playTone(ctx, 523, 0, 0.12, 'sine', 0.18) // C5
playTone(ctx, 659, 0.1, 0.12, 'sine', 0.18) // E5
playTone(ctx, 784, 0.2, 0.12, 'sine', 0.18) // G5
playTone(ctx, 1047, 0.3, 0.3, 'sine', 0.2) // C6
},
// 下降音阶 — C-A-F-D (消极/警示感)
fall: (ctx) => {
playTone(ctx, 523, 0, 0.15, 'sine', 0.18) // C5
playTone(ctx, 440, 0.12, 0.15, 'sine', 0.18) // A4
playTone(ctx, 349, 0.24, 0.15, 'sine', 0.18) // F4
playTone(ctx, 294, 0.36, 0.3, 'sine', 0.18) // D4
},
// 电子提示音 — 锯齿波短促
electronic: (ctx) => {
playTone(ctx, 1200, 0, 0.08, 'sawtooth', 0.1)
playTone(ctx, 1600, 0.06, 0.08, 'sawtooth', 0.1)
playTone(ctx, 1200, 0.12, 0.15, 'sawtooth', 0.1)
},
// 水滴 — 极高频短音, 清脆
drop: (ctx) => {
playTone(ctx, 1800, 0, 0.06, 'sine', 0.15)
playTone(ctx, 2400, 0.04, 0.1, 'sine', 0.12)
},
// 钟声 — 低频持续共鸣
bell: (ctx) => {
playTone(ctx, 523, 0, 0.8, 'sine', 0.15)
playTone(ctx, 784, 0.02, 0.8, 'sine', 0.1) // 泛音
playTone(ctx, 1047, 0.04, 0.6, 'sine', 0.06) // 高泛音
},
// 乒乓 — 两个交替音
pingpong: (ctx) => {
playTone(ctx, 1000, 0, 0.08, 'sine', 0.15)
playTone(ctx, 700, 0.1, 0.08, 'sine', 0.15)
playTone(ctx, 1000, 0.2, 0.08, 'sine', 0.15)
playTone(ctx, 700, 0.3, 0.15, 'sine', 0.15)
},
// 魔法 — 快速上升扫频感
magic: (ctx) => {
playTone(ctx, 400, 0, 0.05, 'sine', 0.12)
playTone(ctx, 600, 0.04, 0.05, 'sine', 0.12)
playTone(ctx, 800, 0.08, 0.05, 'sine', 0.12)
playTone(ctx, 1100, 0.12, 0.05, 'sine', 0.12)
playTone(ctx, 1400, 0.16, 0.2, 'sine', 0.15)
},
}
/** 播放通知声效 (从 localStorage 读配置) */
export function playNotificationSound() {
try {
const enabled = localStorage.getItem('alert_sound_enabled')
if (enabled === '0') return // 关闭声效
const sound = localStorage.getItem('alert_sound') || 'ding'
if (sound === 'none') return
const ctx = getCtx()
if (!ctx) return
const preset = SOUND_PRESETS[sound]
if (preset) preset(ctx)
} catch {
// 音频不可用时静默
}
}
/** 声效选项 (供设置页下拉) */
export const SOUND_OPTIONS = [
{ key: 'ding', label: '清脆叮' },
{ key: 'chime', label: '风铃' },
{ key: 'rise', label: '上升音阶' },
{ key: 'fall', label: '下降音阶' },
{ key: 'alert', label: '急促警报' },
{ key: 'electronic', label: '电子音' },
{ key: 'drop', label: '水滴' },
{ key: 'bell', label: '钟声' },
{ key: 'pingpong', label: '乒乓' },
{ key: 'magic', label: '魔法' },
{ key: 'soft', label: '柔和' },
{ key: 'none', label: '无声' },
]
/** 预览声效 (设置页点"试听"用) */
export function previewSound(sound: string) {
try {
const ctx = getCtx()
if (!ctx) return
const preset = SOUND_PRESETS[sound]
if (preset) preset(ctx)
} catch { /* ignore */ }
}
+84
View File
@@ -0,0 +1,84 @@
/**
* 集中管理所有 React Query key。
*
* - 新增查询只需在此加一行,所有消费方自动引用。
* - SSE invalidation 基于 SSE_INVALIDATE_PREFIXES 列表,新增 key 无需改 useQuoteStream。
*/
// ===== Query Key 工厂 =====
export const QK = {
// 全局 / 共享 (Layout 预取)
capabilities: ['capabilities'] as const,
settings: ['settings'] as const,
endpoints: ['endpoints'] as const,
version: ['version'] as const,
preferences: ['preferences'] as const,
quoteStatus: ['quote-status'] as const,
quoteInterval: ['quote-interval'] as const,
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
indexQuotes: ['index-quotes'] as const,
indexList: ['index-list'] as const,
// Watchlist
watchlist: ['watchlist'] as const,
watchlistQuotes: ['watchlist-quotes'] as const,
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
instrumentSearch: (q: string) => ['instrument-search', q] as const,
// Screener
screener: ['screener'] as const,
screenerStrategies: ['screener-strategies'] as const,
screenerCached: (ext?: string) => ['screener-cached', ext] as const,
screenerKlineBatch: (symbols: string) => ['screener-kline-batch', symbols] as const,
marketSnapshot: ['market-snapshot'] as const,
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
// Backtest
backtestStatus: ['backtest-status'] as const,
// Data / Pipeline
dataStatus: ['data-status'] as const,
pipelineJobs: ['pipeline-jobs'] as const,
pipelineJob: (id: string) => ['pipeline-job', id] as const,
extData: ['ext-data'] as const,
extDataRows: (id: string, date?: string, limit?: number, columns?: string) => ['ext-data-rows', id, date, limit, columns] as const,
analysisMenus: ['analysis-menus'] as const,
analysisMenu: (id: string) => ['analysis-menu', id] as const,
// Kline
kline: (symbol: string, start: string, end: string, extColumns?: string) =>
['kline', symbol, start, end, extColumns ?? ''] as const,
klineMinute: (symbol: string, date: string) =>
['kline-minute', symbol, date] as const,
indexDaily: (symbol: string, start: string, end: string) =>
['index-daily', symbol, start, end] as const,
indexMinute: (symbol: string, date: string) =>
['index-minute', symbol, date] as const,
// Schema
extDataSchemaAll: ['ext-data-schema-all'] as const,
tableSchema: (table: string) => ['table-schema', table] as const,
// Custom Signals
customSignals: ['custom-signals'] as const,
customSignalsOptions: ['custom-signals-options'] as const,
// Monitor (监控规则 + 触发记录)
monitorRules: ['monitor-rules'] as const,
monitorRuleOptions: ['monitor-rule-options'] as const,
alerts: (source?: string) => ['alerts', source ?? ''] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
// 新增需要 SSE 推送的查询,只需在此加一行
export const SSE_INVALIDATE_PREFIXES = [
'watchlist',
'quote-status',
'index-quotes',
'overview-market',
'limit-ladder',
'screener-cached',
] as const
+128
View File
@@ -0,0 +1,128 @@
/**
* 策略结果列表自定义列配置。
*
* 内置列与分组与自选页 (watchlist-columns) 保持一致,额外保留策略特有的
* 「策略」「评分」两列。通用列模型/合并/扩展列参数来自 list-columns。
*/
import { storage } from '@/lib/storage'
import {
buildExtColumnsParam,
mergeColumns,
serializeColumns,
type ColumnConfig,
type ColumnGroup,
type ColumnSource,
type ExtColumnDisplayConfig,
type CandleColumnConfig,
} from '@/lib/list-columns'
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
export { buildExtColumnsParam, mergeColumns, serializeColumns }
export const SCREENER_BUILTIN_COLUMNS: ColumnConfig[] = [
// 固定列
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '标的', visible: true, pinned: true, align: 'left' },
// 策略特有列
{ id: 'builtin:strategies', source: { type: 'builtin', key: 'strategies' }, label: '策略', visible: true, align: 'left' },
{ id: 'builtin:score', source: { type: 'builtin', key: 'score' }, label: '评分', visible: true, align: 'right' },
// 价格
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'right' },
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'right' },
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'right' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'right' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: false, align: 'right' },
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: true, align: 'right' },
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'right' },
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'right' },
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'right' },
// 均线
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'right' },
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'right' },
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'right' },
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'right' },
// 区间
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'right' },
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'right' },
// 技术指标
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'right' },
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'right' },
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'right' },
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'right' },
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'right' },
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'right' },
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'right' },
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'right' },
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'right' },
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'right' },
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'right' },
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'right' },
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'right' },
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'right' },
// 动量
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum_60d' }, label: '60D 动量', visible: true, align: 'right' },
// 连板
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
// 信号 & 图表
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'left' },
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
// 财务指标 (与自选页对齐; 当前后端 enriched 未返回这些字段,默认隐藏)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'right' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'right' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'right' },
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'right' },
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'right' },
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'right' },
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'right' },
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'right' },
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'right' },
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'right' },
]
export const SCREENER_COLUMN_GROUPS: ColumnGroup[] = [
{ id: 'core', label: '核心', icon: '🎯', keys: ['strategies', 'score', 'signals'] },
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
]
export async function saveScreenerColumnConfig(columns: ColumnConfig[]): Promise<void> {
const saveable = serializeColumns(columns)
storage.screenerResultColumns.set(saveable)
try {
const { api } = await import('@/lib/api')
await api.updateScreenerResultColumns(saveable)
} catch {
// 后端不可用时 localStorage 仍有效
}
}
export async function loadScreenerColumnConfig(): Promise<ColumnConfig[]> {
try {
const { api } = await import('@/lib/api')
const res = await api.screenerResultColumns()
if (res.columns && res.columns.length > 0) {
const merged = mergeColumns(res.columns, SCREENER_BUILTIN_COLUMNS)
storage.screenerResultColumns.set(serializeColumns(merged))
return merged
}
} catch {
// 后端不可用,继续尝试 localStorage
}
const saved = storage.screenerResultColumns.get([]) as ColumnConfig[]
if (saved.length > 0) return mergeColumns(saved, SCREENER_BUILTIN_COLUMNS)
return [...SCREENER_BUILTIN_COLUMNS]
}
+170
View File
@@ -0,0 +1,170 @@
/**
* 买卖触发器信号定义 — 选股页弹窗 / 回测页共用。
*
* 信号 ID 必须与后端 backtest/strategy.py:_build_signal_mask 对齐
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
*/
export type SignalKind = 'entry' | 'exit' | 'both'
export interface BuiltinSignalDefinition {
id: string
name: string
kind: SignalKind
category: string
description: string
}
/** 内置原子信号清单 (权威展示来源, 两页统一) */
export const BUILTIN_SIGNAL_DEFINITIONS: BuiltinSignalDefinition[] = [
{
id: 'signal_ma_golden_5_20',
name: 'MA5上穿MA20',
kind: 'entry',
category: '均线',
description: '短期均线 MA5 上穿中期均线 MA20,常用于趋势转强确认。',
},
{
id: 'signal_ma_dead_5_20',
name: 'MA5下穿MA20',
kind: 'exit',
category: '均线',
description: '短期均线 MA5 下穿中期均线 MA20,常用于趋势转弱或止盈止损。',
},
{
id: 'signal_ma_golden_20_60',
name: 'MA20上穿MA60',
kind: 'entry',
category: '均线',
description: '中期均线 MA20 上穿长期均线 MA60,偏中线趋势信号。',
},
{
id: 'signal_macd_golden',
name: 'MACD金叉',
kind: 'entry',
category: 'MACD',
description: 'MACD DIF 上穿 DEA,表示动能可能由弱转强。',
},
{
id: 'signal_macd_dead',
name: 'MACD死叉',
kind: 'exit',
category: 'MACD',
description: 'MACD DIF 下穿 DEA,表示动能可能由强转弱。',
},
{
id: 'signal_ma20_breakout',
name: '突破MA20',
kind: 'entry',
category: '趋势',
description: '收盘价向上突破 MA20,常用于趋势突破买点。',
},
{
id: 'signal_ma20_breakdown',
name: '跌破MA20',
kind: 'exit',
category: '趋势',
description: '收盘价向下跌破 MA20,常用于趋势破位卖点。',
},
{
id: 'signal_n_day_high',
name: '60日新高',
kind: 'entry',
category: '趋势',
description: '收盘价创近 60 日新高,表示阶段强势或突破。',
},
{
id: 'signal_n_day_low',
name: '60日新低',
kind: 'exit',
category: '趋势',
description: '收盘价创近 60 日新低,表示阶段弱势或风险释放。',
},
{
id: 'signal_boll_breakout_upper',
name: '突破布林上轨',
kind: 'entry',
category: 'BOLL',
description: '价格突破布林上轨,偏强势突破或加速信号。',
},
{
id: 'signal_boll_breakdown_lower',
name: '跌破布林下轨',
kind: 'exit',
category: 'BOLL',
description: '价格跌破布林下轨,偏弱势破位或超跌风险信号。',
},
{
id: 'signal_volume_surge',
name: '放量',
kind: 'both',
category: '量价',
description: '成交量显著放大,可作为买入确认、卖出确认或告警条件。',
},
{
id: 'signal_limit_up',
name: '涨停',
kind: 'entry',
category: '涨跌停',
description: '收盘封住涨停,用于强势股、连板与市场情绪监控。',
},
{
id: 'signal_limit_down',
name: '跌停',
kind: 'exit',
category: '涨跌停',
description: '收盘触及跌停,用于风险控制与弱势监控。',
},
{
id: 'signal_limit_down_recovery',
name: '跌停翘板',
kind: 'entry',
category: '涨跌停',
description: '盘中触及跌停后回升,常用于短线情绪修复观察。',
},
{
id: 'signal_broken_limit_up',
name: '炸板',
kind: 'exit',
category: '涨跌停',
description: '盘中触及涨停但收盘未封住,用于强转弱或分歧监控。',
},
]
/** 内置原子信号 → 中文标签 */
export const SIGNAL_LABELS: Record<string, string> = BUILTIN_SIGNAL_DEFINITIONS.reduce<Record<string, string>>((acc, sig) => {
acc[sig.id] = sig.name
return acc
}, {})
/** 内置信号 ID 列表 */
export const SIGNAL_OPTIONS = BUILTIN_SIGNAL_DEFINITIONS.map(sig => sig.id)
/** 常用技术指标/字段 → 中文 (阈值条件展示用, 与后端 ENRICHED_COLUMNS 对齐) */
const FIELD_LABELS: Record<string, string> = {
close: '收盘价', open: '开盘价', high: '最高价', low: '最低价',
change_pct: '涨跌幅', change_amount: '涨跌额', amplitude: '振幅',
turnover_rate: '换手率', volume: '成交量', amount: '成交额',
ma5: 'MA5', ma10: 'MA10', ma20: 'MA20', ma30: 'MA30', ma60: 'MA60',
ema5: 'EMA5', ema10: 'EMA10', ema20: 'EMA20',
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
boll_upper: '布林上轨', boll_lower: '布林下轨',
kdj_k: 'KDJ-K', kdj_d: 'KDJ-D', kdj_j: 'KDJ-J',
rsi_6: 'RSI6', rsi_14: 'RSI14', rsi_24: 'RSI24',
vol_ratio_5d: '5日量比', vol_ratio_20d: '20日量比',
vol_ma5: '5日均量', vol_ma10: '10日均量',
high_60d: '60日最高', low_60d: '60日最低',
momentum_5d: '5日动量', momentum_20d: '20日动量', momentum_60d: '60日动量',
atr_14: 'ATR14', annual_vol_20d: '20日年化波动',
consecutive_limit_ups: '连板数', consecutive_limit_downs: '跌停连板',
}
/**
* 信号/字段 ID → 中文显示名。
* 内置信号查 SIGNAL_LABELS; csg_ 前缀查传入的自定义信号名称映射;
* 技术指标查 FIELD_LABELS; 都找不到则原样返回。
*/
export function cnSignal(name: string, customNames?: Record<string, string>): string {
if (customNames && name in customNames) return customNames[name]
return SIGNAL_LABELS[name] ?? FIELD_LABELS[name] ?? name
}
+82
View File
@@ -0,0 +1,82 @@
/**
* 个股日K信息条(StockInfoBar Row 2)的指标自定义配置。
*
* 与自选列表列配置同源:复用 list-columns 的通用列模型与合并/序列化底座,
* 仅做纯 localStorage 同步持久化(无后端双写)。个股预览弹窗与回测成交K线
* Modal 共用同一份配置。
*/
import { storage } from '@/lib/storage'
import {
buildExtColumnsParam as buildExtColumnsParamBase,
mergeColumns as mergeColumnsBase,
serializeColumns as serializeColumnsBase,
type ColumnConfig,
type ColumnGroup,
} from '@/lib/list-columns'
export type { ColumnConfig, ColumnGroup }
// ===== 内置指标注册表 =====
export const BUILTIN_INFO_FIELDS: ColumnConfig[] = [
// 规模
{ id: 'builtin:market_cap', source: { type: 'builtin', key: 'market_cap' }, label: '市值', visible: true, align: 'left' },
{ id: 'builtin:float_market_cap', source: { type: 'builtin', key: 'float_market_cap' }, label: '流通值', visible: true, align: 'left' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手', visible: true, align: 'left' },
{ id: 'builtin:volume', source: { type: 'builtin', key: 'volume' }, label: '成交量', visible: false, align: 'left' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'left' },
// 行情
{ id: 'builtin:open', source: { type: 'builtin', key: 'open' }, label: '开盘', visible: false, align: 'left' },
{ id: 'builtin:high', source: { type: 'builtin', key: 'high' }, label: '最高', visible: false, align: 'left' },
{ id: 'builtin:low', source: { type: 'builtin', key: 'low' }, label: '最低', visible: false, align: 'left' },
// 财务(数据来自 financials metrics 接口,默认隐藏;pe_ttm/pb 用 close 现算)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'left' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'left' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'left' },
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE', visible: false, align: 'left' },
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'left' },
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'left' },
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'left' },
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'left' },
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'left' },
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'left' },
]
export const INFO_GROUPS: ColumnGroup[] = [
{ id: 'scale', label: '规模', icon: '🏦', keys: ['market_cap', 'float_market_cap'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'volume', 'amplitude'] },
{ id: 'quote', label: '行情', icon: '📈', keys: ['open', 'high', 'low'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'] },
]
// ===== localStorage 持久化 =====
/** 加载信息条指标配置:localStorage → 默认值,自动补齐新增默认项。 */
export function loadInfoFields(): ColumnConfig[] {
const saved = storage.stockInfoBarFields.get([]) as ColumnConfig[]
if (saved.length === 0) return [...BUILTIN_INFO_FIELDS]
return mergeFields(saved, BUILTIN_INFO_FIELDS)
}
/** 保存信息条指标配置到 localStorage。 */
export function saveInfoFields(columns: ColumnConfig[]): void {
storage.stockInfoBarFields.set(serializeFields(columns))
}
/** 序列化(此处无 pinned/action 列,直接用底座默认实现)。 */
function serializeFields(columns: ColumnConfig[]): ColumnConfig[] {
return serializeColumnsBase(columns)
}
/** 从信息条字段配置中提取 ext 列参数(逗号分隔 config_id.field_name),用于 klineDaily 接口。 */
export function buildInfoExtColumnsParam(columns: ColumnConfig[]): string {
return buildExtColumnsParamBase(columns)
}
/** 合并用户保存的配置与默认配置。 */
function mergeFields(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
// 无固定列,传入空的 pinnedFirstIds 跳过「代码置顶」逻辑
return mergeColumnsBase(saved, defaults, { pinnedFirstIds: [] })
}
+108
View File
@@ -0,0 +1,108 @@
/**
* 股票列表共享逻辑(无 JSX):信号提取、排序取值、不可排序列集合。
*
* 自选页与策略页共用,避免两边各维护一份导致口径漂移(历史上策略页 14 个信号、
* 自选页 13 个,缺一个均线金叉/死叉变体)。
*/
import type { ColumnConfig } from '@/lib/list-columns'
// ===== 信号 =====
export type SignalType = 'bull' | 'bear' | 'neutral'
export interface Signal {
label: string
type: SignalType
}
/** 信号字段定义:[行字段名, 展示标签, 信号类型] */
export const SIGNAL_FIELDS: [string, string, SignalType][] = [
['signal_limit_up', '涨停', 'bull'],
['signal_broken_board_recovery', '反包', 'bull'],
['signal_volume_surge', '放量', 'neutral'],
['signal_ma_golden_5_20', 'MA金叉', 'bull'],
['signal_ma_dead_5_20', 'MA死叉', 'bear'],
['signal_ma_golden_20_60', '均线金叉', 'bull'],
['signal_macd_golden', 'MACD金叉', 'bull'],
['signal_macd_dead', 'MACD死叉', 'bear'],
['signal_ma20_breakout', '站上MA20', 'bull'],
['signal_ma20_breakdown', '跌破MA20', 'bear'],
['signal_n_day_high', '60日新高', 'bull'],
['signal_n_day_low', '60日新低', 'bear'],
['signal_boll_breakout_upper', '布林突破', 'bull'],
['signal_boll_breakdown_lower', '布林下破', 'bear'],
]
/** 从一行数据中提取已命中的信号列表 */
export function getSignals(r: Record<string, any>): Signal[] {
return SIGNAL_FIELDS.filter(([key]) => r[key]).map(([, label, type]) => ({ label, type }))
}
/** 信号类型 → tailwind 颜色类 */
export function signalCls(type: SignalType): string {
if (type === 'bull') return 'text-bull bg-bull/10'
if (type === 'bear') return 'text-bear bg-bear/10'
return 'text-accent bg-accent/10'
}
// ===== 排序 =====
/** 不可参与数值/文本排序的内置列 key(渲染为标签/图表,无单一标量值) */
export const UNSORTABLE_KEYS = new Set(['signals', 'candle', 'strategies'])
/**
* 取一列在某行上的排序标量值。builtin 列按 key 映射到行字段;ext 列走
* `${configId}__${fieldName}`;不可排序列返回 null。
*/
export function getSortValue(r: any, col: ColumnConfig): any {
if (col.source.type === 'ext') {
return r[`${col.source.configId}__${col.source.fieldName}`]
}
const key = col.source.key
switch (key) {
case 'symbol': return r.symbol
case 'price': return r.rt_price ?? r.close
case 'pct': return r.rt_pct ?? r.change_pct
case 'change_amount': return r.change_amount
case 'amplitude': return r.amplitude
case 'turnover': return r.turnover_rate
case 'amount': return r.rt_amount ?? r.amount
case 'float_val': return r.float_shares && (r.rt_price ?? r.close) ? r.float_shares * (r.rt_price ?? r.close) : null
case 'vol_ratio': return r.vol_ratio_5d
case 'annual_vol': return r.annual_vol_20d
case 'ma5': return r.ma5
case 'ma10': return r.ma10
case 'ma20': return r.ma20
case 'ma60': return r.ma60
case 'high_60d': return r.high_60d
case 'low_60d': return r.low_60d
case 'rsi6': return r.rsi_6
case 'rsi14': return r.rsi_14
case 'rsi24': return r.rsi_24
case 'macd_dif': return r.macd_dif
case 'macd_dea': return r.macd_dea
case 'macd_hist': return r.macd_hist
case 'kdj_k': return r.kdj_k
case 'kdj_d': return r.kdj_d
case 'kdj_j': return r.kdj_j
case 'boll_upper': return r.boll_upper
case 'boll_lower': return r.boll_lower
case 'atr14': return r.atr_14
case 'vol_ma5': return r.vol_ma5
case 'vol_ma10': return r.vol_ma10
case 'momentum_5d': return r.momentum_5d
case 'momentum_10d': return r.momentum_10d
case 'momentum_20d': return r.momentum_20d
case 'momentum_30d': return r.momentum_30d
case 'momentum_60d': return r.momentum_60d
case 'limit_ups': return r.consecutive_limit_ups ?? 0
case 'limit_downs': return r.consecutive_limit_downs ?? 0
case 'score': return r.score
default: return null
}
}
// ===== 共享样式 =====
/** 数值单元格统一样式(含 tabular-nums 等宽数字) */
export const NUM_CELL_CLASS = 'num tabular-nums'
+113
View File
@@ -0,0 +1,113 @@
/**
* 集中管理所有 localStorage 持久化。
*
* - key 在此注册,各页面只通过 storage.xxx.get/set 调用。
* - 类型安全,不再散落 try/catch。
*/
function kv<T>(key: string) {
return {
get(fallback: T): T {
try {
const raw = localStorage.getItem(key)
if (raw !== null) return JSON.parse(raw) as T
} catch { /* ignore */ }
return fallback
},
set(val: T) {
try { localStorage.setItem(key, JSON.stringify(val)) } catch { /* ignore */ }
},
}
}
export const storage = {
/** 主题偏好: light | dark | system */
theme: kv<'light' | 'dark' | 'system'>('tf-theme'),
/** 查询轮询 / SSE 配置 */
queryConfig: kv<unknown>('tf-stocks-query-config'),
/** 策略池 (screener) */
strategyPool: kv<string[]>('strategy-pool'),
/** 自选列表列配置 */
watchlistColumns: kv<unknown[]>('watchlist_columns'),
/** 个股日K信息条指标配置 */
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
/** 自选列表视图模式 table | card */
watchlistView: kv<string>('watchlist_view'),
/** 自选列表日K蜡烛图显示状态 */
watchlistCandle: kv<boolean>('watchlist_showCandle'),
/** 策略结果列表日K蜡烛图显示状态 */
screenerCandle: kv<boolean>('screener_showCandle'),
/** 自选列表板块筛选 */
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
/** Screener 卡片尺寸 */
screenerCardSize: kv<string>('screener-card-size'),
/** 连板梯队板块筛选 */
limitLadderBoard: kv<string[]>('limit-ladder-board-filter'),
/** 连板梯队 ext 字段配置 */
limitLadderExtFields: kv<Record<string, any>>('limit-ladder-ext-fields'),
/** 连板梯队 概念/行业 显示开关 */
limitLadderShowExt: kv<{ concept: boolean; industry: boolean }>('limit-ladder-show-ext'),
/** 连板梯队 涨停/跌停 切换方向 */
limitLadderDirection: kv<'up' | 'down'>('limit-ladder-direction'),
/** 连板梯队 封单显示模式: vol=按成交量(手), amount=按金额(元) */
limitLadderSealMode: kv<'vol' | 'amount'>('limit-ladder-seal-mode'),
/** 策略创建草稿(新建专用) */
strategyDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-draft'),
/** 策略修改草稿(AI修改专用,不影响创建按钮) */
strategyModify: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-modify'),
/** 策略构建器草稿(旧版兼容,逐渐废弃) */
strategyBuilderDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-builder-draft'),
/** 已保存策略的原始规则(策略ID → 规则文本) */
strategyRules: kv<Record<string, string>>('strategy-rules'),
/** 策略回测快捷区间按钮配置 */
strategyBacktestQuickRanges: kv<unknown>('strategy-backtest-quick-ranges'),
/** 策略回测最后一次成功结果和参数 */
strategyBacktestLast: kv<{
selectedStrategy: string | null
symbols: string
start: string
end: string
matching: 'close_t' | 'open_t+1'
entryFill: 'close_t' | 'open_t+1'
exitFill: 'close_t' | 'open_t+1'
fees: string
maxPositions: string
maxExposure: string
initialCapital: string
positionSizing: 'equal' | 'score_weight'
mode: 'position' | 'full'
holdingDays: string
params?: Record<string, any>
overrides?: Record<string, any>
result: any
} | null>('strategy-backtest-last'),
/** 概念分析页面字段配置 */
conceptAnalysisConfig: kv<Record<string, any>>('concept-analysis-config'),
/** 行业分析页面字段配置 */
industryAnalysisConfig: kv<Record<string, any>>('industry-analysis-config'),
} as const
+72
View File
@@ -0,0 +1,72 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
export const FINANCIAL_QK = {
status: ['financials', 'status'],
metrics: (symbol?: string) => ['financials', 'metrics', symbol],
income: (symbol?: string) => ['financials', 'income', symbol],
balanceSheet: (symbol?: string) => ['financials', 'balance-sheet', symbol],
cashFlow: (symbol?: string) => ['financials', 'cash-flow', symbol],
}
export function useFinancialStatus() {
return useQuery({
queryKey: FINANCIAL_QK.status,
queryFn: () => api.financialStatus(),
staleTime: 60_000,
// 同步进行中时每 3s 轮询,及时反映表数变化与同步完成;空闲时不轮询。
refetchInterval: (query) => (query.state.data?.syncing ? 3_000 : false),
})
}
export function useFinancialMetrics(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.metrics(symbol),
queryFn: () => api.financialMetrics(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialIncome(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.income(symbol),
queryFn: () => api.financialIncome(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialBalanceSheet(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.balanceSheet(symbol),
queryFn: () => api.financialBalanceSheet(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialCashFlow(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.cashFlow(symbol),
queryFn: () => api.financialCashFlow(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialSync() {
const qc = useQueryClient()
return useMutation({
mutationFn: (table: string) => api.financialSync(table),
// 点击瞬间立即刷新 status: 让后端 is_syncing=True 马上反映到 UI,
// 避免 mutation 阻塞(全量同步需数分钟)期间界面无变化。
onMutate: () => {
qc.invalidateQueries({ queryKey: FINANCIAL_QK.status })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: FINANCIAL_QK.status })
qc.invalidateQueries({ queryKey: ['financials'] })
},
})
}
+40
View File
@@ -0,0 +1,40 @@
/**
* SSE 配置 — 运行时参数。
*
* 目前只保留 SSE 重连延迟,其他数据刷新全部走 SSE invalidation。
* 存储在 localStorage。
*/
import { storage } from '@/lib/storage'
// ===== 配置结构 =====
export interface QueryConfig {
/** SSE 配置 */
sse: {
reconnectDelay: number
}
}
export const DEFAULT_QUERY_CONFIG: QueryConfig = {
sse: {
reconnectDelay: 5_000,
},
}
// ===== localStorage 持久化 =====
function loadConfig(): QueryConfig {
const raw = storage.queryConfig.get(null) as QueryConfig | null
if (!raw) return DEFAULT_QUERY_CONFIG
return {
sse: { ...DEFAULT_QUERY_CONFIG.sse, ...raw.sse },
}
}
/**
* 轻量版:只读取当前配置。
* 供 useQuoteStream 使用。
*/
export function getQueryConfig(): QueryConfig {
return loadConfig()
}
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useRef, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { SSE_INVALIDATE_PREFIXES } from './queryKeys'
import { getQueryConfig } from './useQueryConfig'
import { toast } from '@/components/Toast'
import { pushAlertToasts } from '@/components/AlertToast'
import type { StrategyAlertEvent } from './api'
/**
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
*
* - 行情更新 (quotes_updated): 根据 sseRefreshPages 配置过滤 invalidation
* - 策略监控通知 (strategy_alert): 通过 onAlert 回调弹 toast
*
* 应在顶层 Layout 中调用一次。
*/
export function useQuoteStream(
enabled: boolean,
sseRefreshPages: Record<string, boolean> | undefined,
onAlert?: (alerts: StrategyAlertEvent[]) => void,
) {
const qc = useQueryClient()
const esRef = useRef<EventSource | null>(null)
const retryRef = useRef<ReturnType<typeof setTimeout>>()
const pagesRef = useRef(sseRefreshPages)
pagesRef.current = sseRefreshPages
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
// depth 系统接管通知: 单独处理, 不走 strategy 回调
const depthAlerts = alerts.filter(a => a.source === 'depth')
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
for (const a of depthAlerts.slice(0, 1)) {
toast(a.message, 'success')
}
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
if (strategyAlerts.length > 0) {
// 有 onAlert 回调时走回调, 否则弹 AlertToast
if (onAlert) {
onAlert(strategyAlerts)
}
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
pushAlertToasts(strategyAlerts as any)
}
}, [onAlert])
const enabledRef = useRef(enabled)
enabledRef.current = enabled
useEffect(() => {
// SSE 始终连接 — 监控告警不依赖实时行情开关
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
const connect = () => {
const es = new EventSource('/api/intraday/stream')
esRef.current = es
// sse-starlette ping 心跳走 SSE comment,不会到达这里
es.addEventListener('quotes_updated', () => {
// 实时行情未开启时不处理行情刷新
if (!enabledRef.current) return
// 根据用户配置过滤 invalidation
const pages = pagesRef.current
if (pages) {
// 只 invalidate 开启的页面对应的 prefix
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
// 'quote-status' 始终刷新 (全局状态)
if (p === 'quote-status') return true
return pages[p] !== false
})
qc.invalidateQueries({
predicate: (query) =>
activePrefixes.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
} else {
// 无配置时全部刷新 (向后兼容)
qc.invalidateQueries({
predicate: (query) =>
SSE_INVALIDATE_PREFIXES.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
}
})
es.addEventListener('depth_updated', () => {
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
qc.invalidateQueries({ queryKey: ['overview-market'] })
})
es.addEventListener('strategy_alert', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
const alerts: StrategyAlertEvent[] = data.alerts || []
if (alerts.length > 0) {
handleAlerts(alerts)
// 实时刷新触发记录列表 + 监控中心徽标
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
}
} catch {
// 忽略解析错误
}
})
es.onerror = () => {
es.close()
esRef.current = null
const delay = getQueryConfig().sse.reconnectDelay
retryRef.current = setTimeout(connect, delay)
}
}
connect()
return () => {
clearTimeout(retryRef.current)
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
}
}, [qc, handleAlerts])
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 共享 mutation hooks — 消除多页面重复的 useMutation 调用。
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './api'
import { QK } from './queryKeys'
/** 切换实时行情 — Layout / Data 共用 */
export function useToggleRealtimeQuotes() {
const qc = useQueryClient()
return useMutation({
mutationFn: (enabled: boolean) => api.updateRealtimeQuotes(enabled),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
}
/** 更新行情轮询间隔 — Layout / Data 共用 */
export function useUpdateQuoteInterval() {
const qc = useQueryClient()
return useMutation({
mutationFn: (v: number) => api.updateQuoteInterval(v),
onSuccess: (data) => {
qc.setQueryData(QK.quoteInterval, data)
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
}
/** 批量添加自选 — Screener / Intraday 共用 */
export function useWatchlistBatchAdd() {
const qc = useQueryClient()
return useMutation({
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
},
})
}
+71
View File
@@ -0,0 +1,71 @@
/**
* 共享 query hooks — 消除多页面重复的 useQuery 调用。
*
* 实时数据走 SSE invalidation,无需前端轮询。
* 只有管线进度等非 SSE 数据才用 refetchInterval。
*/
import { useQuery } from '@tanstack/react-query'
import { api } from './api'
import { QK } from './queryKeys'
// ===== 全局共享 =====
/** 能力检测 — Layout / Data / Keys 共用 */
export function useCapabilities() {
return useQuery({
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
}
/** 设置状态 — Layout / Data / Keys 共用 */
export function useSettings() {
return useQuery({
queryKey: QK.settings,
queryFn: api.settings,
})
}
/** 用户偏好 — Layout / Data / Intraday 共用 */
export function usePreferences() {
return useQuery({
queryKey: QK.preferences,
queryFn: api.preferences,
})
}
/** 行情状态 — SSE quotes_updated 自动刷新 */
export function useQuoteStatus(opts?: { enabled?: boolean }) {
return useQuery({
queryKey: QK.quoteStatus,
queryFn: api.quoteStatus,
enabled: opts?.enabled ?? true,
})
}
/** 行情间隔 — Layout / Data 共用 */
export function useQuoteInterval() {
return useQuery({
queryKey: QK.quoteInterval,
queryFn: api.quoteInterval,
})
}
/** 版本号 — Layout 专用 */
export function useVersion() {
return useQuery({
queryKey: QK.version,
queryFn: api.version,
staleTime: Infinity,
})
}
/** 数据状态 — Data / Screener 共用 */
export function useDataStatus(opts?: { staleTime?: number; refetchInterval?: number | false }) {
return useQuery({
queryKey: QK.dataStatus,
queryFn: api.dataStatus,
staleTime: opts?.staleTime,
refetchInterval: opts?.refetchInterval,
})
}
+36
View File
@@ -0,0 +1,36 @@
import { useState, useCallback, useEffect } from 'react'
import { storage } from '@/lib/storage'
export function useStrategyPool() {
const [pool, setPool] = useState<string[]>(() => storage.strategyPool.get([]))
// 同步写入 localStorage
useEffect(() => { storage.strategyPool.set(pool) }, [pool])
const addToPool = useCallback((id: string) => {
setPool(prev => prev.includes(id) ? prev : [...prev, id])
}, [])
const removeFromPool = useCallback((id: string) => {
setPool(prev => prev.filter(x => x !== id))
}, [])
const reorderPool = useCallback((newOrder: string[]) => {
setPool(newOrder)
}, [])
// 清除池中不存在于 validIds 的失效策略(如本地开发残留的自定义策略)。
// 仅当确实有失效项时才更新,避免无谓重渲染。
const prune = useCallback((validIds: Iterable<string>) => {
const validSet = validIds instanceof Set ? validIds : new Set(validIds)
setPool(prev => {
if (prev.length === 0) return prev
const next = prev.filter(id => validSet.has(id))
return next.length === prev.length ? prev : next
})
}, [])
const isInPool = useCallback((id: string) => pool.includes(id), [pool])
return { pool, addToPool, removeFromPool, reorderPool, prune, isInPool }
}
+166
View File
@@ -0,0 +1,166 @@
/**
* 自选列表自定义列配置。
*
* 自选页只保留业务内置列、分组和偏好持久化;通用列模型/合并/扩展列参数
* 来自 list-columns,策略页等其它股票列表可复用同一底座。
*/
import { storage } from '@/lib/storage'
import {
buildExtColumnsParam as buildExtColumnsParamBase,
createExtColumn as createExtColumnBase,
mergeColumns as mergeColumnsBase,
serializeColumns as serializeColumnsBase,
type ColumnConfig,
type ColumnGroup,
type ColumnSource,
type ExtColumnDisplayConfig,
type CandleColumnConfig,
} from '@/lib/list-columns'
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
// ===== 内置列注册表(与当前硬编码一一对应) =====
export const BUILTIN_COLUMNS: ColumnConfig[] = [
// 固定列
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '代码/名称', visible: true, pinned: true, align: 'left' },
// 价格
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'center' },
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'center' },
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'center' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'center' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: true, align: 'center' },
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: false, align: 'center' },
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'center' },
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'center' },
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'center' },
// 均线
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'center' },
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'center' },
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'center' },
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'center' },
// 区间
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'center' },
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'center' },
// 技术指标
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'center' },
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'center' },
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'center' },
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'center' },
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'center' },
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'center' },
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'center' },
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'center' },
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'center' },
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'center' },
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'center' },
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'center' },
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'center' },
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'center' },
// 动量
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum' }, label: '60D 动量', visible: true, align: 'center' },
// 连板
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
// 信号 & 图表
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'center' },
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
// 财务指标 (需 Expert 套餐 financial capability, 列默认隐藏)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'center' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'center' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'center' },
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'center' },
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'center' },
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'center' },
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'center' },
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'center' },
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'center' },
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'center' },
]
export const COLUMN_GROUPS: ColumnGroup[] = [
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
]
// 操作列(始终显示,不参与自定义)
export const ACTION_COLUMN_ID = 'builtin:action'
// ===== localStorage 持久化 =====
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action */
export function serializeColumns(columns: ColumnConfig[]): ColumnConfig[] {
return serializeColumnsBase(columns, ACTION_COLUMN_ID)
}
/** 序列化并保存到后端 + localStorage */
export async function saveColumnConfig(columns: ColumnConfig[]): Promise<void> {
const saveable = serializeColumns(columns)
// 同时写 localStorage(即时)和后端(持久化)
storage.watchlistColumns.set(saveable)
try {
const { api } = await import('@/lib/api')
await api.updateWatchlistColumns(saveable)
} catch {
// 后端不可用时 localStorage 仍有效
}
}
/** 加载列配置:优先后端,回退 localStorage,最终用默认值 */
export async function loadColumnConfig(): Promise<ColumnConfig[]> {
// 1. 尝试从后端加载
try {
const { api } = await import('@/lib/api')
const res = await api.watchlistColumns()
if (res.columns && res.columns.length > 0) {
const merged = mergeColumns(res.columns, BUILTIN_COLUMNS)
// 同步到 localStorage
storage.watchlistColumns.set(serializeColumns(merged))
return merged
}
} catch {
// 后端不可用,继续尝试 localStorage
}
// 2. 尝试从 localStorage 加载
const saved = storage.watchlistColumns.get([]) as ColumnConfig[]
if (saved.length > 0) {
return mergeColumns(saved, BUILTIN_COLUMNS)
}
// 3. 默认值
return [...BUILTIN_COLUMNS]
}
/** 合并用户保存的列与默认列 */
function mergeColumns(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
return mergeColumnsBase(saved, defaults, { actionColumnId: ACTION_COLUMN_ID })
}
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口 */
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
return buildExtColumnsParamBase(columns)
}
/** 根据 ext schema 数据创建 ext 列配置 */
export function createExtColumn(
configId: string,
configLabel: string,
fieldName: string,
fieldLabel?: string,
): ColumnConfig {
return createExtColumnBase(configId, configLabel, fieldName, fieldLabel)
}
+26
View File
@@ -0,0 +1,26 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './router'
import { ThemeProvider } from './components/ThemeProvider'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5_000, // 5s 内复用,与 §4.2 Repository 不变量一致
refetchOnWindowFocus: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ThemeProvider>
</React.StrictMode>
)
+228
View File
@@ -0,0 +1,228 @@
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>
)
}
+291
View File
@@ -0,0 +1,291 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { BarChart3, ChevronDown, ChevronUp, Plus, Save, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { api, type AnalysisColumn, type ExtDataConfig, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
function dtypeToColumnType(dtype: string): AnalysisColumn['type'] {
return dtype === 'int' || dtype === 'float' ? 'number' : 'string'
}
function buildColumn(field: ExtDataField): AnalysisColumn {
return {
field: field.name,
label: field.label || field.name,
type: dtypeToColumnType(field.dtype),
precision: field.dtype === 'float' ? 2 : null,
sortable: field.dtype === 'int' || field.dtype === 'float',
visible: true,
}
}
function firstMatchingField(config: ExtDataConfig | undefined, keywords: string[]) {
if (!config) return ''
for (const keyword of keywords) {
const lower = keyword.toLowerCase()
const matched = config.fields.find(f => f.name.toLowerCase().includes(lower) || f.label.toLowerCase().includes(lower))
if (matched) return matched.name
}
return config.fields.find(f => !['symbol', 'code'].includes(f.name) && f.dtype === 'string')?.name ?? ''
}
export function Analysis() {
const qc = useQueryClient()
const menus = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus })
const extData = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const configs = extData.data?.items ?? []
const [showCreate, setShowCreate] = useState(false)
const [id, setId] = useState('')
const [label, setLabel] = useState('')
const [dataSource, setDataSource] = useState('')
const [template, setTemplate] = useState<'dimension_rank' | 'ranking' | 'table'>('dimension_rank')
const [dimensionField, setDimensionField] = useState('')
const [rankField, setRankField] = useState('')
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
const [error, setError] = useState('')
const menuItems = menus.data?.items ?? []
const activeConfig = configs.find(c => c.id === dataSource) ?? configs[0]
const fields = activeConfig?.fields ?? []
const resetForm = () => {
const cfg = configs[0]
setId('')
setLabel('')
setDataSource(cfg?.id ?? '')
setTemplate('dimension_rank')
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setRankField('')
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
setError('')
}
const save = useMutation({
mutationFn: () => {
const cfg = activeConfig
if (!cfg) throw new Error('请选择扩展数据源')
if (!id.trim()) throw new Error('请输入菜单标识')
if (!label.trim()) throw new Error('请输入菜单名称')
if (template === 'dimension_rank' && !dimensionField) throw new Error('请选择分组字段')
if (template === 'ranking' && !rankField) throw new Error('请选择排名字段')
const detailColumns = selectedColumns
.map(name => cfg.fields.find(f => f.name === name))
.filter(Boolean)
.map(f => buildColumn(f as ExtDataField))
const groupColumns: AnalysisColumn[] = template === 'dimension_rank'
? [
{ field: '__dimension', label: cfg.fields.find(f => f.name === dimensionField)?.label || '分组', type: 'string', visible: true },
{ field: '__count', label: '股票数', type: 'number', sortable: true, visible: true },
...detailColumns.filter(c => c.type === 'number').slice(0, 2).map(c => ({ ...c, label: `平均${c.label || c.field}`, aggregate: 'avg' as const })),
]
: []
return api.analysisMenuSave(id.trim(), {
label: label.trim(),
icon: template === 'dimension_rank' ? 'tags' : 'chart',
data_source: cfg.id,
template,
dimension_field: template === 'dimension_rank' ? dimensionField : null,
rank_field: template === 'ranking' ? rankField : null,
group_columns: groupColumns,
detail_columns: detailColumns,
default_sort: template === 'ranking' && rankField ? { field: rankField, order: 'desc' } : null,
visible: true,
order: menuItems.length + 100,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.analysisMenus })
setShowCreate(false)
resetForm()
},
onError: (err) => setError(String((err as any)?.message ?? err)),
})
const del = useMutation({
mutationFn: api.analysisMenuDelete,
onSuccess: () => qc.invalidateQueries({ queryKey: QK.analysisMenus }),
})
const reorder = useMutation({
mutationFn: api.analysisMenuReorder,
onSuccess: () => qc.invalidateQueries({ queryKey: QK.analysisMenus }),
})
const moveMenu = (idx: number, dir: -1 | 1) => {
const next = [...menuItems]
const target = idx + dir
if (target < 0 || target >= next.length) return
const [item] = next.splice(idx, 1)
next.splice(target, 0, item)
reorder.mutate(next.map(m => m.id))
}
const numericFields = useMemo(() => fields.filter(f => f.dtype === 'int' || f.dtype === 'float'), [fields])
return (
<>
<PageHeader
title="扩展分析"
subtitle="自定义分析菜单 · 动态字段 · 动态列"
right={
<button
onClick={() => { resetForm(); setShowCreate(v => !v) }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
}
/>
<div className="px-8 py-6 max-w-6xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(139,92,246,0.14),transparent_38%)]">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] text-secondary">
<BarChart3 className="h-3.5 w-3.5" />
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-foreground"></h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
使
</p>
</section>
{showCreate && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input value={id} onChange={e => setId(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))} placeholder="如 concept_hot" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input value={label} onChange={e => setLabel(e.target.value)} placeholder="如 概念热度" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select
value={dataSource || activeConfig?.id || ''}
onChange={e => {
const cfg = configs.find(c => c.id === e.target.value)
setDataSource(e.target.value)
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
}}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{configs.map(cfg => <option key={cfg.id} value={cfg.id}>{cfg.label}</option>)}
</select>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={template} onChange={e => setTemplate(e.target.value as any)} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
<option value="dimension_rank"></option>
<option value="ranking"></option>
<option value="table"></option>
</select>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={dimensionField} onChange={e => setDimensionField(e.target.value)} disabled={template !== 'dimension_rank'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
<option value=""></option>
{fields.map(f => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
</select>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={rankField} onChange={e => setRankField(e.target.value)} disabled={template !== 'ranking'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
<option value=""></option>
{numericFields.map(f => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
</select>
</label>
</div>
<div>
<div className="text-[11px] text-muted mb-2"></div>
<div className="flex flex-wrap gap-2">
{fields.filter(f => !['symbol', 'code'].includes(f.name)).map(f => {
const active = selectedColumns.includes(f.name)
return (
<button
key={f.name}
onClick={() => setSelectedColumns(cols => active ? cols.filter(c => c !== f.name) : [...cols, f.name])}
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${active ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-elevated/40 text-secondary hover:bg-elevated'}`}
>
{f.label || f.name}
</button>
)
})}
</div>
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={() => setShowCreate(false)} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</section>
)}
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{menuItems.map((menu, idx) => (
<div key={menu.id} className="rounded-card border border-border bg-surface p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground">{menu.label}</h3>
<p className="mt-1 text-[11px] text-muted font-mono">{menu.id}</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => moveMenu(idx, -1)}
disabled={idx === 0 || reorder.isPending}
className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 disabled:opacity-30"
title="上移"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<button
onClick={() => moveMenu(idx, 1)}
disabled={idx === menuItems.length - 1 || reorder.isPending}
className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 disabled:opacity-30"
title="下移"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
{menu.builtin ? (
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent"></span>
) : (
<button onClick={() => del.mutate(menu.id)} disabled={del.isPending} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10">
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="mt-3 space-y-1 text-[11px] text-secondary">
<div><span className="font-mono text-muted">{menu.data_source}</span></div>
<div>{menu.template}</div>
{menu.dimension_field && <div>{menu.dimension_field}</div>}
<div>{menu.detail_columns.length} </div>
</div>
<Link to={`/analysis/${menu.id}`} className="mt-4 inline-flex w-full items-center justify-center rounded-btn border border-border bg-elevated px-3 py-1.5 text-xs text-foreground hover:bg-border/30 transition-colors">
</Link>
</div>
))}
{menuItems.length === 0 && (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted md:col-span-2 xl:col-span-3"></div>
)}
</section>
</div>
</>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { useParams } from 'react-router-dom'
import { ExtDimensionAnalysis } from '@/components/ExtDimensionAnalysis'
export function AnalysisDetail() {
const { menuId } = useParams()
return <ExtDimensionAnalysis menuId={menuId} />
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { PageHeader } from '@/components/PageHeader'
import { FactorBacktest } from './backtest/FactorBacktest'
import { StrategyBacktest } from './backtest/StrategyBacktest'
import { BarChart3, FlaskConical } from 'lucide-react'
type Tab = 'factor' | 'strategy'
const MODES: Record<Tab, { title: string; subtitle: string; hint: string }> = {
factor: {
title: '因子回测',
subtitle: '验证单个因子是否有预测能力',
hint: '看 IC / IR、分层收益和多空组合,适合先筛掉无效指标。',
},
strategy: {
title: '策略回测',
subtitle: '验证完整选股和交易规则',
hint: '看净值曲线、回撤、胜率和交易明细,适合判断策略是否可执行。',
},
}
export function Backtest() {
const [activeTab, setActiveTab] = useState<Tab>('strategy')
const modeSwitch = (
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5 shadow-sm">
{(['factor', 'strategy'] as const).map(tab => {
const Icon = tab === 'factor' ? BarChart3 : FlaskConical
const active = activeTab === tab
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
active
? 'bg-accent text-white shadow-sm'
: 'text-secondary hover:bg-elevated hover:text-foreground'
}`}
>
<Icon className="h-3.5 w-3.5" />
{MODES[tab].title}
</button>
)
})}
</div>
)
return (
<div className="min-h-full bg-base flex flex-col">
<PageHeader
title="回测工作台"
subtitle={`${MODES[activeTab].title} · ${MODES[activeTab].hint}`}
right={modeSwitch}
className="shrink-0 bg-base/95"
/>
<main className="flex-1 min-h-0 px-3 pb-3 pt-3 lg:px-4 lg:pb-4">
{activeTab === 'factor' && <FactorBacktest />}
{activeTab === 'strategy' && <StrategyBacktest />}
</main>
</div>
)
}
+203
View File
@@ -0,0 +1,203 @@
import { motion } from 'framer-motion'
import {
RadioTower,
Square,
GitFork,
Sparkles,
Star,
LineChart,
ScanSearch,
History,
Signal as SignalIcon,
Eye,
FileText,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
interface Variant {
id: string
name: string
tagline: string
hint: string
icon: React.ComponentType<{ className?: string }>
iconAccent: string // tailwind text color
nameClass: string // 文字本身样式(字号/字重/字距/字体)
glow?: string // 名字下方的发光线条 hex
}
// 同一个名字 "Stock Panel" 在 4 种风格语言里的呈现
// 长字符串自动用更小字号 + 更窄字距,免得撑爆卡片;但风格语言(字体/字重/配色/图标)保持不变
const VARIANTS: Variant[] = [
{
id: 'pulsar',
name: 'Stock Panel',
tagline: 'A-SHARE · SIGNAL TERMINAL',
hint: '脉冲星、雷达波纹 — 青绿强调色,字重黑体,中等字距',
icon: RadioTower,
iconAccent: 'text-[#3DD68C]',
nameClass: 'font-sans font-black text-base tracking-[0.10em]',
glow: '#3DD68C',
},
{
id: 'vanta',
name: 'Stock Panel',
tagline: 'MARKET · INTELLIGENCE',
hint: 'Vantablack — 纯白单色,字重最重,字距最宽,monochrome 高级感',
icon: Square,
iconAccent: 'text-[#FAFAFA]',
nameClass: 'font-sans font-black text-base tracking-[0.18em]',
glow: '#FAFAFA',
},
{
id: 'helix',
name: 'Stock Panel',
tagline: 'QUANT · TERMINAL',
hint: 'DNA 螺旋 — 紫色强调,等宽字体,赛博朋克经典意象',
icon: GitFork,
iconAccent: 'text-[#8B5CF6]',
nameClass: 'font-mono font-bold text-base tracking-[0.08em]',
glow: '#8B5CF6',
},
{
id: 'aurora',
name: 'Stock Panel',
tagline: 'A-SHARE · DASHBOARD',
hint: '极光 — 青色强调,细字优雅,适中字距,与涨跌语义色不冲突',
icon: Sparkles,
iconAccent: 'text-[#22D3EE]',
nameClass: 'font-sans font-light text-base tracking-[0.12em]',
glow: '#22D3EE',
},
]
const MOCK_NAV = [
{ icon: Star, label: '自选' },
{ icon: LineChart, label: 'K 线' },
{ icon: ScanSearch, label: '策略' },
{ icon: History, label: '回测' },
{ icon: SignalIcon, label: '信号' },
{ icon: Eye, label: '监控' },
{ icon: FileText, label: '财务' },
]
export function Branding() {
return (
<>
<PageHeader
title="视觉风格预览"
subtitle="名字保持 Stock Panel,4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
/>
<div className="px-8 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{VARIANTS.map((v) => (
<Sample key={v.id} v={v} />
))}
</div>
<div className="mt-8 rounded-card border border-border bg-surface p-5 text-sm text-secondary leading-relaxed max-w-2xl">
<div className="font-medium text-foreground mb-2">?</div>
<code className="font-mono text-accent">pulsar / vanta / helix / aurora</code> ,
("用 VANTA 但换青色"),
</div>
</div>
</>
)
}
function Sample({ v }: { v: Variant }) {
const Icon = v.icon
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="rounded-card border border-border overflow-hidden bg-base flex"
>
{/* 模拟侧边栏 */}
<div className="w-56 bg-surface border-r border-border flex flex-col">
{/* Logo 区 */}
<div className="px-5 py-5 border-b border-border">
<div className="flex items-center gap-2.5">
<div
className="grid place-items-center h-7 w-7 rounded-md"
style={{
background: `${v.glow}1a`,
boxShadow: `0 0 12px ${v.glow}33`,
}}
>
<Icon className={`h-4 w-4 ${v.iconAccent}`} />
</div>
<div className={`${v.nameClass} text-foreground leading-none`}>
{v.name}
</div>
</div>
<div className="mt-2 text-[10px] uppercase tracking-[0.18em] text-secondary">
{v.tagline}
</div>
<div
className="mt-3 h-px"
style={{
background: `linear-gradient(90deg, ${v.glow}66, transparent)`,
}}
/>
<div className="mt-2 text-xs text-secondary">
· <span className="text-foreground font-medium font-mono">Pro</span>
</div>
</div>
{/* 模拟导航 */}
<nav className="px-2 py-3 space-y-0.5">
{MOCK_NAV.slice(0, 5).map(({ icon: I, label }, i) => (
<div
key={label}
className={`flex items-center gap-3 px-3 py-2 rounded-btn text-sm ${
i === 0
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/80'
}`}
>
<I className="h-4 w-4" />
{label}
</div>
))}
</nav>
</div>
{/* 右侧说明 + 大字预览 */}
<div className="flex-1 p-5 flex flex-col">
<div className="flex-1">
<div className="text-xs font-medium text-muted uppercase tracking-widest">{v.id}</div>
<div className="mt-2 leading-relaxed text-sm text-secondary">
{v.hint}
</div>
</div>
{/* 大字 wordmark 预览 */}
<div className="mt-6 pt-6 border-t border-border">
<div
className={`${v.nameClass} text-foreground`}
style={{
textShadow: `0 0 24px ${v.glow}55`,
}}
>
{v.name}
</div>
<div className="mt-1.5 text-[10px] uppercase tracking-[0.2em] text-secondary">
{v.tagline}
</div>
</div>
{/* 模拟一个数据卡片,看与配色协调度 */}
<div className="mt-5 rounded-btn bg-surface border border-border px-3 py-2 flex items-baseline justify-between">
<span className="text-xs text-secondary">600519.SH</span>
<span className="font-mono text-sm" style={{ color: v.glow }}>
+1.85%
</span>
</div>
</div>
</motion.div>
)
}
+761
View File
@@ -0,0 +1,761 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
Activity,
Crown,
Database,
Layers3,
RefreshCw,
Search,
Settings2,
TrendingDown,
TrendingUp,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format'
import { cn } from '@/lib/cn'
import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter'
const KEYWORDS = ['concept', '概念', 'theme', '题材', '板块']
const CANDIDATE_FIELDS = ['concept', '概念', 'theme', '题材', '板块', 'concept_name', '概念名称']
const PAGE_LIMIT = 12000
const MAX_RENDERED_CONCEPTS = 120
const MAX_RENDERED_STOCKS = 160
type SortMode = 'heat' | 'avgPct' | 'leader' | 'amount' | 'down'
interface EnrichedStock extends MarketSnapshotRow {
leaderScore: number
leaderParts: {
momentum: number
turnover: number
amount: number
cap: number
volume: number
boards: number
}
}
interface ConceptStat {
key: string
stocks: EnrichedStock[]
count: number
avgPct: number | null
medianPct: number | null
upCount: number
downCount: number
flatCount: number
upRate: number
strongCount: number
weakCount: number
totalAmount: number
avgTurnover: number | null
avgVolRatio: number | null
leader: EnrichedStock | null
heatScore: number
riskScore: number
}
function loadConfig(): AnalysisFieldConfig {
return storage.conceptAnalysisConfig.get({}) as AnalysisFieldConfig
}
function saveConfig(c: AnalysisFieldConfig) {
storage.conceptAnalysisConfig.set(c)
}
function pickBestConfig(
configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[],
): string {
let best = ''
let bestScore = 0
for (const c of configs) {
const haystack = [c.id, c.label, c.description ?? '', ...c.fields.flatMap(f => [f.name, f.label])].join(' ').toLowerCase()
const score = KEYWORDS.reduce((n, k) => n + (haystack.includes(k) ? 1 : 0), 0)
if (score > bestScore) {
bestScore = score
best = c.id
}
}
return best
}
function symbolKeys(symbol: unknown): string[] {
const raw = String(symbol ?? '').trim()
if (!raw) return []
const plain = raw.replace(/\.\w+$/, '')
return Array.from(new Set([raw, plain]))
}
function buildMarketMap(rows: MarketSnapshotRow[]) {
const map = new Map<string, MarketSnapshotRow>()
for (const r of rows) {
for (const key of symbolKeys(r.symbol)) map.set(key, r)
}
return map
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function avg(values: number[]) {
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null
}
function median(values: number[]) {
if (!values.length) return null
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
function leaderScore(stock: MarketSnapshotRow) {
const pct = num(stock.change_pct) ?? 0
const turnover = num(stock.turnover_rate) ?? 0
const amount = num(stock.amount) ?? 0
const cap = num(stock.float_market_cap) ?? num(stock.market_cap) ?? 0
const volRatio = num(stock.vol_ratio_5d) ?? 1
const boards = num(stock.consecutive_limit_ups) ?? 0
const parts = {
momentum: clamp01((pct + 0.02) / 0.12),
turnover: clamp01(Math.log1p(Math.max(turnover, 0)) / Math.log1p(30)),
amount: clamp01(Math.log1p(Math.max(amount, 0)) / Math.log1p(20_000_000_000)),
cap: clamp01(Math.log1p(Math.max(cap, 0)) / Math.log1p(300_000_000_000)),
volume: clamp01((volRatio - 1) / 4),
boards: clamp01(boards / 5),
}
const score = (
parts.momentum * 0.35 +
parts.turnover * 0.22 +
parts.amount * 0.18 +
parts.cap * 0.15 +
parts.volume * 0.07 +
parts.boards * 0.03
) * 100
return { score, parts }
}
function enrichStock(stock: StockRow, marketMap: Map<string, MarketSnapshotRow>): EnrichedStock {
const market = symbolKeys(stock.symbol ?? stock.code).map(k => marketMap.get(k)).find(Boolean) ?? {}
const merged = { ...stock, ...market } as MarketSnapshotRow & StockRow
const ls = leaderScore(merged)
return {
...merged,
symbol: String(merged.symbol ?? stock.symbol ?? stock.code ?? ''),
name: merged.name ?? String(stock.name ?? stock['股票简称'] ?? ''),
leaderScore: ls.score,
leaderParts: ls.parts,
}
}
function calcConceptStat(group: DimensionGroup, marketMap: Map<string, MarketSnapshotRow>): ConceptStat {
const seen = new Set<string>()
const stocks = group.stocks
.map(s => enrichStock(s, marketMap))
.filter(s => {
const key = String(s.symbol ?? '')
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
const pctValues = stocks.map(s => num(s.change_pct)).filter((v): v is number => v != null)
const turnoverValues = stocks.map(s => num(s.turnover_rate)).filter((v): v is number => v != null)
const volValues = stocks.map(s => num(s.vol_ratio_5d)).filter((v): v is number => v != null)
const totalAmount = stocks.reduce((sum, s) => sum + (num(s.amount) ?? 0), 0)
const upCount = pctValues.filter(v => v > 0).length
const downCount = pctValues.filter(v => v < 0).length
const flatCount = Math.max(0, stocks.length - upCount - downCount)
const strongCount = pctValues.filter(v => v >= 0.05).length
const weakCount = pctValues.filter(v => v <= -0.05).length
const leader = stocks.length ? [...stocks].sort((a, b) => b.leaderScore - a.leaderScore)[0] : null
const avgPct = avg(pctValues)
const medianPct = median(pctValues)
const upRate = pctValues.length ? upCount / pctValues.length : 0
const amountScore = clamp01(Math.log1p(totalAmount) / Math.log1p(80_000_000_000))
const strongScore = stocks.length ? clamp01(strongCount / Math.max(1, stocks.length * 0.18)) : 0
const leaderPart = clamp01((leader?.leaderScore ?? 0) / 100)
const avgPart = clamp01(((avgPct ?? 0) + 0.02) / 0.09)
const upPart = clamp01((upRate - 0.35) / 0.55)
const heatScore = (avgPart * 0.38 + upPart * 0.2 + strongScore * 0.16 + amountScore * 0.12 + leaderPart * 0.14) * 100
const riskScore = (clamp01((-(avgPct ?? 0) + 0.01) / 0.08) * 0.55 + clamp01(weakCount / Math.max(1, stocks.length * 0.18)) * 0.45) * 100
return {
key: group.key,
stocks,
count: stocks.length,
avgPct,
medianPct,
upCount,
downCount,
flatCount,
upRate,
strongCount,
weakCount,
totalAmount,
avgTurnover: avg(turnoverValues),
avgVolRatio: avg(volValues),
leader,
heatScore,
riskScore,
}
}
function statSort(mode: SortMode) {
return (a: ConceptStat, b: ConceptStat) => {
switch (mode) {
case 'avgPct': return (b.avgPct ?? -Infinity) - (a.avgPct ?? -Infinity)
case 'leader': return (b.leader?.leaderScore ?? -Infinity) - (a.leader?.leaderScore ?? -Infinity)
case 'amount': return b.totalAmount - a.totalAmount
case 'down': return (a.avgPct ?? Infinity) - (b.avgPct ?? Infinity)
case 'heat':
default: return b.heatScore - a.heatScore
}
}
}
export function ConceptAnalysis() {
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
const [showConfig, setShowConfig] = useState(false)
const [search, setSearch] = useState('')
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
// 用户配置的 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({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
enabled: !!activeConfigId,
})
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
staleTime: 60_000,
})
const marketMap = useMemo(() => buildMarketMap(marketQuery.data?.rows ?? []), [marketQuery.data?.rows])
const resolved = useMemo(
() => resolveDimension(rowsQuery.data, activeConfig, fieldConfig.dimensionField ? [fieldConfig.dimensionField, ...CANDIDATE_FIELDS] : CANDIDATE_FIELDS),
[rowsQuery.data, activeConfig, fieldConfig.dimensionField],
)
const stats = useMemo(() => {
return resolved.groups
.map(g => calcConceptStat(g, marketMap))
.filter(s => s.count > 0)
}, [resolved.groups, marketMap])
const filteredStats = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q ? stats.filter(s => s.key.toLowerCase().includes(q)) : stats
return [...base].sort(statSort(sortMode))
}, [stats, search, sortMode])
const selected = filteredStats.find(s => s.key === selectedKey) ?? filteredStats[0] ?? null
const leading = useMemo(() => [...stats].sort(statSort('heat')).slice(0, 10), [stats])
const falling = useMemo(() => [...stats].sort(statSort('down')).slice(0, 10), [stats])
const activeConcept = useMemo(() => [...stats].sort(statSort('amount'))[0] ?? null, [stats])
const conceptBreadth = useMemo(() => {
const priced = stats.filter(s => s.avgPct != null)
return {
up: priced.filter(s => (s.avgPct ?? 0) > 0).length,
down: priced.filter(s => (s.avgPct ?? 0) < 0).length,
flat: priced.filter(s => s.avgPct === 0).length,
}
}, [stats])
const totalSymbols = useMemo(() => {
const set = new Set<string>()
stats.forEach(s => s.stocks.forEach(st => { if (st.symbol) set.add(st.symbol) }))
return set.size
}, [stats])
const handleSaveConfig = (c: AnalysisFieldConfig) => {
setFieldConfig(c)
saveConfig(c)
setSelectedKey(null)
}
if (configsQuery.isLoading) {
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div>
}
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>
)
}
return (
<>
<PageHeader
title="概念分析"
subtitle={`${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
className="p-1.5 text-muted hover:bg-surface disabled:opacity-50"
title="刷新"
>
<RefreshCw className={cn('h-4 w-4', (rowsQuery.isFetching || marketQuery.isFetching) && 'animate-spin')} />
</button>
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
</div>
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_12%_0%,rgba(59,130,246,0.12),transparent_28%),radial-gradient(circle_at_85%_8%,rgba(244,63,94,0.08),transparent_28%)] px-6 py-5">
<div className="mx-auto max-w-[1440px] space-y-5">
<HeroPanel leading={leading[0]} falling={falling[0]} activeConcept={activeConcept} conceptBreadth={conceptBreadth} />
<MarketPulse
leading={leading}
falling={falling}
selectedKey={selected?.key ?? null}
onSelect={setSelectedKey}
onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }}
/>
{stats.length > 0 ? (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[18rem_1fr]">
<ConceptRail
stats={filteredStats.slice(0, MAX_RENDERED_CONCEPTS)}
selectedKey={selected?.key ?? null}
search={search}
sortMode={sortMode}
onSearch={v => { setSearch(v); setSelectedKey(null) }}
onSort={setSortMode}
onSelect={setSelectedKey}
/>
<ConceptFocus stat={selected} onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div>
) : (
<EmptyState icon={Layers3} title="未匹配到概念数据" hint={resolved.hint || '请检查扩展数据是否包含概念/题材相关字段'} />
)}
</div>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
</AnimatePresence>
{previewSymbol && (
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => { setPreviewSymbol(null); setPreviewName('') }}
/>
)}
</>
)
}
function HeroPanel({
leading,
falling,
activeConcept,
conceptBreadth,
}: {
leading?: ConceptStat
falling?: ConceptStat
activeConcept?: ConceptStat | null
conceptBreadth: { up: number; down: number; flat: number }
}) {
return (
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
<HeroMetric icon={TrendingUp} label="最强主线" value={leading?.key ?? '—'} hint={leading?.avgPct != null ? <span className={priceColorClass(leading.avgPct)}>{fmtPct(leading.avgPct)}</span> : '等待行情'} tone="up" />
<HeroMetric icon={TrendingDown} label="最大风险" value={falling?.key ?? '—'} hint={falling?.avgPct != null ? <span className={priceColorClass(falling.avgPct)}>{fmtPct(falling.avgPct)}</span> : '等待行情'} tone="down" />
<HeroMetric
icon={Activity}
label="涨跌板块"
value={<><span className="text-bull">{conceptBreadth.up}</span><span className="mx-1 text-muted">/</span><span className="text-bear">{conceptBreadth.down}</span></>}
hint={<><span className="text-bull"></span><span className="mx-1 text-muted">/</span><span className="text-bear"></span>{conceptBreadth.flat ? <span className="text-muted"> · {conceptBreadth.flat}</span> : null}</>}
tone="blue"
/>
<HeroMetric icon={Activity} label="资金活跃" value={activeConcept?.key ?? '—'} hint={activeConcept ? fmtBigNum(activeConcept.totalAmount) : '等待行情'} tone="blue" />
<HeroMetric icon={Crown} label="龙头算法" value="6 因子" hint="强势 + 承接 + 容量" tone="gold" />
</div>
)
}
function HeroMetric({ icon: Icon, label, value, hint, tone }: {
icon: typeof TrendingUp
label: string
value: ReactNode
hint: ReactNode
tone: 'up' | 'down' | 'gold' | 'blue'
}) {
const toneClass = {
up: 'text-bull bg-bull/10',
down: 'text-bear bg-bear/10',
gold: 'text-amber-300 bg-amber-400/10',
blue: 'text-blue-300 bg-blue-400/10',
}[tone]
const valueClass = {
up: 'text-bull',
down: 'text-bear',
gold: 'text-amber-300',
blue: 'text-foreground',
}[tone]
return (
<div className="rounded-xl border border-border bg-surface px-3 py-2">
<div className="flex items-center justify-between text-[11px] text-muted">
<span>{label}</span>
<span className={cn('rounded-md p-1', toneClass)}><Icon className="h-3.5 w-3.5" /></span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', valueClass)}>{value}</div>
<div className="mt-0.5 truncate text-[11px] text-muted">{hint}</div>
</div>
)
}
function MarketPulse({
leading,
falling,
selectedKey,
onSelect,
onStockClick,
}: {
leading: ConceptStat[]
falling: ConceptStat[]
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string) => void
}) {
return (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
</div>
)
}
function PulseList({
title,
items,
mode,
selectedKey,
onSelect,
onStockClick,
}: {
title: string
items: ConceptStat[]
mode: 'up' | 'down'
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string) => void
}) {
const toneText = mode === 'up' ? 'text-bull' : 'text-bear'
const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20'
const toneBg = mode === 'up' ? 'bg-bull/10' : 'bg-bear/10'
const toneHover = mode === 'up' ? 'hover:border-bull/35' : 'hover:border-bear/35'
return (
<div className={cn('rounded-xl border bg-base/35 p-2', toneBorder)}>
<div className="mb-1.5 flex items-center justify-between px-1">
<div className={cn('flex items-center gap-1.5 text-xs font-medium', toneText)}>
{mode === 'up' ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
{title}
</div>
<span className="rounded-full bg-elevated/60 px-2 py-0.5 text-[10px] text-muted">Top 10</span>
</div>
<div className="space-y-1">
{items.map((item, idx) => {
const active = selectedKey === item.key
const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3)
const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0
const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0
const flatPct = Math.max(0, 100 - upPct - downPct)
return (
<button
key={item.key}
onClick={() => onSelect(item.key)}
className={cn(
'block w-full rounded-lg border border-transparent bg-surface/45 px-2 py-1.5 text-left transition-colors',
active ? cn('bg-blue-400/[0.08]', toneBorder) : cn('hover:bg-elevated/35', toneHover),
)}
>
<div className="grid gap-2 md:grid-cols-[minmax(0,0.9fr)_minmax(16rem,1.1fr)] md:items-center">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md font-mono text-[10px]', idx < 3 ? cn(toneBg, toneText) : 'bg-elevated/70 text-muted')}>{idx + 1}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{item.key}</span>
<span className="shrink-0 text-[10px] text-muted">
<span className="text-bull">{item.upCount}</span>
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{item.downCount}</span>
</span>
<span className={cn('ml-auto shrink-0 font-mono text-[10px] tabular-nums', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
</div>
</div>
<div className="ml-7 mt-1">
<div className="flex h-1 overflow-hidden rounded-full bg-elevated">
<div className="h-full bg-bull/70" style={{ width: `${upPct}%` }} />
{flatPct > 0 && <div className="h-full bg-muted/25" style={{ width: `${flatPct}%` }} />}
<div className="h-full bg-bear/70" style={{ width: `${downPct}%` }} />
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-3 gap-1">
{Array.from({ length: 3 }).map((_, i) => {
const stock = leaders[i]
return stock ? (
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary')}>
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
</span>
<span className={cn('shrink-0 font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</span>
) : <span key={i} className="rounded-md bg-elevated/30 px-1.5 py-0.5 text-[10px] text-muted/40"></span>
})}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
function ConceptRail({
stats,
selectedKey,
search,
sortMode,
onSearch,
onSort,
onSelect,
}: {
stats: ConceptStat[]
selectedKey: string | null
search: string
sortMode: SortMode
onSearch: (v: string) => void
onSort: (v: SortMode) => void
onSelect: (v: string) => void
}) {
return (
<section className="rounded-2xl border border-border bg-surface p-2.5">
<div className="px-1 pb-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground"></h3>
<span className="text-[10px] text-muted">Top {stats.length}</span>
</div>
<div className="mt-2 relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder="搜索概念" className="h-8 w-full rounded-lg border border-border bg-base pl-8 pr-3 text-xs text-foreground outline-none focus:border-accent/50" />
</div>
<div className="mt-2 grid grid-cols-5 overflow-hidden rounded-lg border border-border text-[10px]">
{([
['heat', '强度'], ['avgPct', '涨幅'], ['leader', '龙头'], ['amount', '成交'], ['down', '跌幅'],
] as [SortMode, string][]).map(([key, label]) => (
<button key={key} onClick={() => onSort(key)} className={cn('py-1.5 transition-colors', sortMode === key ? 'bg-accent/15 text-accent' : 'bg-base text-muted hover:text-foreground')}>{label}</button>
))}
</div>
</div>
<div className="max-h-[620px] overflow-auto rounded-lg border border-border/50">
{stats.map(item => {
const active = selectedKey === item.key
return (
<button key={item.key} onClick={() => onSelect(item.key)} className={cn('w-full border-b border-border/50 px-2.5 py-2 text-left transition-colors last:border-b-0', active ? 'bg-blue-400/[0.08]' : 'hover:bg-elevated/40')}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{item.key}</span>
<span className={cn('font-mono text-xs', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted">
<span>{item.count}</span>
<span className="text-bull">{item.upCount}</span>
<span className="text-bear">{item.downCount}</span>
<span className="ml-auto"> {item.heatScore.toFixed(0)}</span>
</div>
</button>
)
})}
</div>
</section>
)
}
function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string) => void }) {
if (!stat) return null
const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS)
const topLeaders = stocks.slice(0, 3)
return (
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<h3 className="truncate text-xl font-semibold text-foreground">{stat.key}</h3>
<span className="rounded-full bg-blue-400/10 px-2 py-0.5 text-[10px] text-blue-300"> {stat.heatScore.toFixed(0)}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<span>{stat.count} </span>
<span className={priceColorClass(stat.avgPct)}> {stat.avgPct != null ? fmtPct(stat.avgPct) : '—'}</span>
<span> {(stat.upRate * 100).toFixed(0)}%</span>
<span> {fmtBigNum(stat.totalAmount)}</span>
{stat.avgTurnover != null && <span> {stat.avgTurnover.toFixed(2)}%</span>}
</div>
</div>
<div className="grid grid-cols-5 gap-2 lg:w-[520px]">
<MiniStat label="均涨" value={stat.avgPct != null ? fmtPct(stat.avgPct) : '—'} cls={priceColorClass(stat.avgPct)} />
<MiniStat label="中位" value={stat.medianPct != null ? fmtPct(stat.medianPct) : '—'} cls={priceColorClass(stat.medianPct)} />
<MiniStat label="强势" value={`${stat.strongCount}`} cls="text-bull" />
<MiniStat label="弱势" value={`${stat.weakCount}`} cls="text-bear" />
<MiniStat label="量比" value={stat.avgVolRatio != null ? stat.avgVolRatio.toFixed(2) : '—'} cls="text-foreground" />
</div>
</div>
</div>
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
<LeaderStage stocks={topLeaders} onStockClick={onStockClick} />
<ScoreExplain stock={topLeaders[0]} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/60 text-[11px] text-muted">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{stocks.map((s, idx) => (
<tr key={`${s.symbol}-${idx}`} className="hover:bg-elevated/30 cursor-pointer" onClick={() => onStockClick(s.symbol, s.name || undefined)}>
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
<td className="px-4 py-2">
<div className="font-medium text-foreground">{s.name || '—'}</div>
<div className="font-mono text-[10px] text-muted">{s.symbol}</div>
</td>
<td className={cn('px-4 py-2 font-mono tabular-nums', priceColorClass(s.change_pct))}>{s.change_pct != null ? fmtPct(s.change_pct) : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.turnover_rate != null ? `${s.turnover_rate.toFixed(2)}%` : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.amount)}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.float_market_cap ?? s.market_cap)}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.vol_ratio_5d != null ? s.vol_ratio_5d.toFixed(2) : '—'}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<span className="w-9 font-mono text-amber-300">{s.leaderScore.toFixed(0)}</span>
<div className="h-1.5 w-16 rounded-full bg-elevated"><div className="h-full rounded-full bg-amber-300" style={{ width: `${Math.max(4, s.leaderScore)}%` }} /></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{stat.stocks.length > MAX_RENDERED_STOCKS && <div className="shrink-0 border-t border-border px-4 py-2 text-center text-[11px] text-muted"> {MAX_RENDERED_STOCKS} {stat.stocks.length} </div>}
</section>
)
}
function MiniStat({ label, value, cls }: { label: string; value: string; cls: string }) {
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
}
function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) {
if (!stocks.length) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted"></div>
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-amber-300">
<Crown className="h-3.5 w-3.5" />
</div>
<div className="grid gap-2 md:grid-cols-3">
{stocks.map((stock, idx) => (
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}>
<div className="flex items-center justify-between gap-2">
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
</div>
<div className="mt-2 truncate text-sm font-medium text-foreground">{stock.name || stock.symbol}</div>
<div className="mt-0.5 flex items-center justify-between text-[11px]">
<span className="font-mono text-muted">{stock.symbol}</span>
<span className={cn('font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</div>
</div>
))}
</div>
</div>
)
}
function ScoreExplain({ stock }: { stock?: EnrichedStock }) {
if (!stock) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted"></div>
const parts = stock.leaderParts
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-foreground"></span>
<span className="text-[11px] text-muted"> / / / / / </span>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
<Part label="动能" value={parts.momentum} cls="bg-rose-400" />
<Part label="换手" value={parts.turnover} cls="bg-orange-400" />
<Part label="成交" value={parts.amount} cls="bg-blue-400" />
<Part label="市值" value={parts.cap} cls="bg-cyan-400" />
<Part label="量比" value={parts.volume} cls="bg-purple-400" />
<Part label="连板" value={parts.boards} cls="bg-amber-300" />
</div>
</div>
)
}
function Part({ label, value, cls }: { label: string; value: number; cls: string }) {
return <div className="rounded-lg bg-base/35 px-2 py-1.5"><div className="mb-1 flex justify-between text-[10px] text-muted"><span>{label}</span><span>{Math.round(value * 100)}</span></div><div className="h-1 rounded-full bg-elevated"><div className={cn('h-full rounded-full', cls)} style={{ width: `${Math.max(3, value * 100)}%` }} /></div></div>
}
+714
View File
@@ -0,0 +1,714 @@
import { useState, 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 { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct } from '@/lib/format'
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals'
import { boardTag } from '@/components/stock-table/primitives'
function n(v: number | null | undefined) {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function scoreColor(v: number) {
// A 股惯例: 强势=红, 弱式=绿
if (v >= 70) return '#F04438'
if (v >= 55) return '#FB923C'
if (v >= 45) return '#F59E0B'
if (v >= 30) return '#84CC16'
return '#12B76A'
}
function fmtPrice(v: number | null | undefined, digits = 2) {
const x = n(v)
return x == null ? '—' : x.toFixed(digits)
}
function fmtIndexPct(v: number | null | undefined) {
const x = n(v)
if (x == null) return '—'
return `${x >= 0 ? '+' : ''}${x.toFixed(2)}%`
}
function fmtStockPct(v: number | null | undefined) {
const x = n(v)
if (x == null) return '—'
return `${x >= 0 ? '+' : ''}${(x * 100).toFixed(2)}%`
}
function pctClass(v: number | null | undefined) {
const x = n(v)
if (x == null || x === 0) return 'text-muted'
return x > 0 ? 'text-bull' : 'text-bear'
}
function quoteAge(ms?: number | null) {
if (ms == null) return '—'
if (ms < 1000) return `${Math.round(ms)}ms`
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m${s % 60}s`
}
function compactCount(v: number | null | undefined) {
const x = n(v)
if (x == null) return '—'
if (x >= 1000) return `${(x / 1000).toFixed(1)}k`
return x.toFixed(0)
}
function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: ReactNode }) {
return (
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-accent" />
<h2 className="text-xs font-semibold text-foreground">{title}</h2>
</div>
{hint && <span className="font-mono text-[10px] text-muted">{hint}</span>}
</div>
)
}
// 看板监控中心小组件 — 显示前 10 条触发记录 + 更多按钮
const _SOURCE_BADGE: Record<string, string> = {
strategy: 'bg-amber-400/10 text-amber-400',
signal: 'bg-accent/10 text-accent',
price: 'bg-emerald-400/10 text-emerald-400',
market: 'bg-purple-500/10 text-purple-400',
}
const _SOURCE_LABEL: Record<string, string> = {
strategy: '策略', signal: '信号', price: '价格', market: '异动',
}
const _SEVERITY_BAR: Record<string, string> = {
info: 'bg-accent/40', warn: 'bg-warning', critical: 'bg-danger',
}
function MonitorWidget() {
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(null)
const alerts = useQuery({
queryKey: ['alerts', ''],
queryFn: () => api.alertsList({ days: 7, limit: 10 }),
refetchInterval: 10000,
refetchIntervalInBackground: true,
})
const events: AlertEvent[] = alerts.data?.alerts ?? []
if (events.length === 0) {
return (
<div className="mt-1 py-6 text-center text-[11px] text-muted"></div>
)
}
return (
<>
<div className="mt-1 space-y-1.5">
{events
.filter((ev: AlertEvent) => !(ev.source === 'strategy' && !ev.symbol))
.map((ev, i) => {
const sev = _SEVERITY_BAR[ev.severity ?? 'info'] ?? _SEVERITY_BAR.info
const pct = ev.change_pct ?? 0
const isStrategy = ev.source === 'strategy'
const sm = isStrategy ? ev.message?.match(/策略「([^」]+)」/) : null
const sname = sm ? sm[1] : ''
const isNew = ev.type === 'new_entry'
return (
<motion.div
key={`${ev.ts}-${i}`}
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
className="relative overflow-hidden rounded-md border border-border/40 bg-surface/60 pl-2.5 pr-2 py-1.5 hover:border-border hover:bg-surface transition-colors"
>
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 (点击代码/名称弹日K) */}
<div className="flex items-center gap-1.5">
<button
onClick={() => ev.symbol && setPreviewEv(ev)}
title={ev.symbol ? `查看 ${ev.symbol} 日K` : undefined}
className="inline-flex items-center gap-1 min-w-0 shrink-0 rounded hover:bg-elevated/60 transition-colors -mx-0.5 px-0.5 cursor-pointer"
>
<span className="font-mono text-[10px] font-medium text-foreground/80 hover:text-accent">{ev.symbol?.replace(/\.(SH|SZ|BJ)$/, '')}</span>
{ev.symbol && (() => {
const board = boardTag(ev.symbol)
return board && (
<span className={`inline-flex items-center justify-center h-3 w-3 rounded text-[7px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
)
})()}
{ev.name && <span className="text-[10px] text-secondary truncate max-w-[5rem] hover:text-foreground">{ev.name}</span>}
</button>
<span className="flex-1" />
{ev.price != null && (
<span className="text-[10px] font-mono text-foreground/60 shrink-0">{fmtPrice(ev.price)}</span>
)}
{ev.change_pct != null && (
<span className={cn('text-[10px] font-mono font-medium shrink-0 w-12 text-right', pct >= 0 ? 'text-danger' : 'text-bear')}>
{fmtPct(pct)}
</span>
)}
</div>
{/* 第二行: 策略类型走新格式, 其他走旧格式 */}
{isStrategy ? (
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn('text-[9px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
{isNew ? '进入' : '移出'}
</span>
<span className="text-[9px] text-muted"></span>
<span className="text-[9px] font-medium text-amber-400">{sname}</span>
<span className="flex-1" />
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
) : (
<>
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn('shrink-0 rounded px-1 py-px text-[8px] font-medium', _SOURCE_BADGE[ev.source] ?? 'bg-elevated text-muted')}>
{_SOURCE_LABEL[ev.source] ?? ev.source}
</span>
{ev.message && (
<span className="text-[9px] text-muted truncate flex-1">{ev.message}</span>
)}
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{ev.signals.map((s, j) => (
<span key={j} className="rounded bg-accent/8 px-1 py-px text-[8px] text-accent/80">{cnSignal(s)}</span>
))}
</div>
)}
</>
)}
</motion.div>
)
})}
</div>
<StockPreviewDialog
symbol={previewEv?.symbol ?? null}
name={previewEv?.name ?? undefined}
triggerInfo={previewEv ? {
price: previewEv.price ?? null,
changePct: previewEv.change_pct ?? null,
ts: previewEv.ts,
signals: previewEv.signals,
message: previewEv.message,
} : null}
onClose={() => setPreviewEv(null)}
/>
</>
)
}
function KpiCell({ label, value, sub, tone = 'neutral' }: { label: ReactNode; value: ReactNode; sub?: string; tone?: 'bull' | 'bear' | 'accent' | 'neutral' }) {
const isPlain = typeof value === 'string' || typeof value === 'number'
const color = tone === 'bull' ? 'text-bull' : tone === 'bear' ? 'text-bear' : tone === 'accent' ? 'text-accent' : 'text-foreground'
return (
<div className="min-w-0 rounded-lg border border-border bg-surface/80 px-3 py-2">
<div className="flex items-center gap-1 text-[11px] text-muted">{label}</div>
<div className={`mt-1 truncate font-mono text-lg font-semibold leading-none tabular-nums ${isPlain ? color : 'text-foreground'}`}>{value}</div>
{sub && <div className="mt-1 truncate text-[10px] text-muted">{sub}</div>}
</div>
)
}
function IndexTicker({ item }: { item: OverviewMarket['indices'][number] }) {
const pct = item.change_pct
const isUp = (n(pct) ?? 0) >= 0
return (
<Link
to={`/indices?symbol=${encodeURIComponent(item.symbol)}`}
className="grid min-w-0 grid-cols-[1fr_auto] items-center gap-x-2 gap-y-0.5 rounded-lg border border-border bg-elevated/45 px-2.5 py-1.5 transition-colors hover:border-accent/40 hover:bg-elevated"
>
<div className="truncate text-xs font-medium text-foreground">{item.name || item.symbol}</div>
<div className={`font-mono text-xs font-semibold ${pctClass(pct)}`}>{fmtIndexPct(pct)}</div>
<div className="font-mono text-[10px] text-muted">{item.symbol}</div>
<div className={`flex items-center gap-1 font-mono text-[11px] ${pctClass(pct)}`}>
{isUp ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
{fmtPrice(item.last_price)}
</div>
</Link>
)
}
function BreadthBar({ data }: { data: OverviewMarket['breadth'] }) {
const denom = Math.max(data.total, 1)
const upW = data.up / denom * 100
const downW = data.down / denom * 100
const flatW = Math.max(0, 100 - upW - downW)
return (
<div className="space-y-2">
<div className="flex h-2.5 overflow-hidden rounded-full bg-elevated">
<div className="bg-bull/85" style={{ width: `${upW}%` }} />
<div className="bg-muted/45" style={{ width: `${flatW}%` }} />
<div className="bg-bear/85" style={{ width: `${downW}%` }} />
</div>
<div className="grid grid-cols-3 gap-1.5 text-[11px]">
<div className="rounded bg-bull/8 px-2 py-1 text-bull"> <span className="font-mono">{data.up}</span></div>
<div className="rounded bg-elevated/70 px-2 py-1 text-muted"> <span className="font-mono">{data.flat}</span></div>
<div className="rounded bg-bear/8 px-2 py-1 text-bear"> <span className="font-mono">{data.down}</span></div>
</div>
</div>
)
}
function DistributionBars({ rows }: { rows: OverviewMarket['distribution'] }) {
const maxCount = Math.max(...rows.map(r => r.count), 1)
return (
<div className="grid h-24 grid-cols-8 items-end gap-1 pt-1">
{rows.map((r, i) => {
const positive = i >= 4
return (
<div key={r.label} className="flex h-full min-w-0 flex-col items-center justify-end gap-0.5">
<div className="font-mono text-[9px] text-muted">{r.count || ''}</div>
<div
className={`w-2 rounded-full ${positive ? 'bg-gradient-to-t from-bull/45 to-bull/90' : 'bg-gradient-to-t from-bear/45 to-bear/90'}`}
style={{ height: `${Math.max(4, r.count / maxCount * 86)}%` }}
title={`${r.label}: ${r.count}`}
/>
<div className="truncate text-[9px] text-muted">{r.label}</div>
</div>
)
})}
</div>
)
}
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
const radius = maxR * Math.max(0, Math.min(100, r.value)) / 100
return {
...r,
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
lx: cx + Math.cos(angle) * (maxR + 27),
ly: cy + Math.sin(angle) * (maxR + 27),
gx: cx + Math.cos(angle) * maxR,
gy: cy + Math.sin(angle) * maxR,
}
})
const polygon = points.map(p => `${p.x},${p.y}`).join(' ')
const gridPolygons = [1, 0.66, 0.33].map((level, idx) => ({
level,
idx,
points: radar.map((_, i) => {
const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length
return `${cx + Math.cos(angle) * maxR * level},${cy + Math.sin(angle) * maxR * level}`
}).join(' '),
}))
return (
<div className="flex justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-56 w-full">
<defs>
<radialGradient id="emotionRadarFill" cx="50%" cy="45%" r="70%">
<stop offset="0%" stopColor={`${color}57`} />
<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)" />
</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}
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} />)}
<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" />)}
<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 => (
<text key={`${p.key}-label`} x={p.lx} y={p.ly + 4} textAnchor="middle" className="fill-secondary text-[10px] font-medium">{p.label}</text>
))}
</svg>
</div>
)
}
function LadderMini({ limit }: { limit: OverviewMarket['limit'] }) {
const tiers = limit.tiers.filter(t => t.boards >= 2).slice(0, 6)
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between rounded bg-elevated/55 px-2 py-1.5 text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-accent">{(limit.seal_rate ?? 0).toFixed(0)}%</span>
</div>
{tiers.length === 0 && <div className="rounded border border-dashed border-border py-5 text-center text-xs text-muted"> 2 </div>}
{tiers.map(t => (
<div key={t.boards} className="grid grid-cols-[42px_1fr_auto] items-center gap-2 rounded bg-elevated/35 px-2 py-1.5">
<span className={`font-mono text-sm font-bold ${t.boards >= 5 ? 'text-bull' : t.boards >= 3 ? 'text-accent' : 'text-secondary'}`}>{t.boards}</span>
<div className="h-1.5 overflow-hidden rounded-full bg-base">
<div className="h-full rounded-full bg-bull/70" style={{ width: `${Math.min(100, t.count * 12)}%` }} />
</div>
<span className="font-mono text-xs text-foreground">{t.count}</span>
</div>
))}
</div>
)
}
function MiniMetric({ label, value, cls = 'text-foreground' }: { label: string; value: string; cls?: string }) {
return (
<div className="rounded bg-elevated/45 px-2 py-1.5">
<div className="text-[10px] text-muted">{label}</div>
<div className={`mt-0.5 font-mono text-xs font-semibold ${cls}`}>{value}</div>
</div>
)
}
function StockList({ title, rows, mode }: { title: string; rows: MarketSnapshotRow[]; mode: 'gain' | 'loss' | 'amount' | 'active' }) {
return (
<div className="rounded-card border border-border bg-surface/80 p-2.5">
<div className="mb-1.5 flex items-center justify-between">
<h3 className="text-xs font-semibold text-foreground">{title}</h3>
<span className="text-[9px] text-muted">TOP {Math.min(rows.length, 8)}</span>
</div>
<div className="space-y-1">
{rows.slice(0, 8).map((r, idx) => (
<div key={`${r.symbol}-${idx}`} className="grid grid-cols-[18px_1fr_auto] items-center gap-1.5 rounded bg-elevated/40 px-1.5 py-1">
<span className="text-center font-mono text-[10px] text-muted">{idx + 1}</span>
<div className="min-w-0">
<div className="truncate text-[11px] text-foreground">{r.name || r.symbol}</div>
<div className="font-mono text-[9px] text-muted">{r.symbol}</div>
</div>
<div className="text-right">
{mode === 'amount' ? (
<>
<div className="font-mono text-[11px] text-foreground">{fmtBigNum(r.amount)}</div>
<div className={`font-mono text-[9px] ${pctClass(r.change_pct)}`}>{fmtStockPct(r.change_pct)}</div>
</>
) : mode === 'active' ? (
<>
{/* overview 的 turnover_rate 为小数制, 需 ×100 转百分数显示 */}
<div className="font-mono text-[11px] text-accent">{fmtPrice(r.turnover_rate != null ? r.turnover_rate * 100 : null, 1)}%</div>
<div className={`font-mono text-[9px] ${pctClass(r.change_pct)}`}>{fmtStockPct(r.change_pct)}</div>
</>
) : (
<>
<div className={`font-mono text-[11px] font-semibold ${pctClass(r.change_pct)}`}>{fmtStockPct(r.change_pct)}</div>
<div className="font-mono text-[9px] text-muted">{fmtPrice(r.close)}</div>
</>
)}
</div>
</div>
))}
{rows.length === 0 && <div className="py-5 text-center text-xs text-muted"></div>}
</div>
</div>
)
}
function RankColumn({ title, rows, tone }: { title: string; rows: OverviewDimensionRankItem[]; tone: 'bull' | 'bear' }) {
return (
<div className="min-w-0 space-y-1">
<div className={`text-[10px] font-medium ${tone === 'bull' ? 'text-bull' : 'text-bear'}`}>{title}</div>
{rows.slice(0, 5).map((r, idx) => (
<div key={`${title}-${r.name}-${idx}`} className="grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded bg-elevated/40 px-1.5 py-1">
<span className="text-center font-mono text-[9px] text-muted">{idx + 1}</span>
<div className="min-w-0">
<div className="truncate text-[11px] text-foreground" title={r.name}>{r.name}</div>
<div className="truncate text-[9px] text-muted">{r.count} · {r.leader?.name ?? '—'}</div>
</div>
<div className={`font-mono text-[10px] font-semibold ${pctClass(r.avg_pct)}`}>{fmtStockPct(r.avg_pct)}</div>
</div>
))}
{rows.length === 0 && <div className="rounded border border-dashed border-border py-4 text-center text-xs text-muted"></div>}
</div>
)
}
function HotRankCard({ title, rank, configUrl }: { title: string; rank?: OverviewMarket['concept_rank']; configUrl: string }) {
const hasData = (rank?.leading?.length ?? 0) > 0 || (rank?.lagging?.length ?? 0) > 0
return (
<section className="rounded-card border border-border bg-surface/80 p-2.5">
<SectionTitle icon={Flame} title={title} hint="领涨/领跌" />
{hasData ? (
<div className="grid grid-cols-2 gap-2">
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" />
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" />
</div>
) : (
<div className="py-4 text-center">
<p className="text-[11px] text-muted"></p>
<Link
to={configUrl}
className="mt-1.5 inline-block text-[11px] text-accent hover:text-accent/80 transition-colors"
>
</Link>
</div>
)}
</section>
)
}
export function Dashboard() {
const [selectedDate, setSelectedDate] = useState<string | undefined>()
const [manualFetching, setManualFetching] = useState(false)
const dataStatus = useDataStatus({ staleTime: 60_000 })
const overview = useQuery({
queryKey: QK.overviewMarket(selectedDate),
queryFn: () => api.overviewMarket(selectedDate),
staleTime: 5_000,
placeholderData: (prev) => prev,
})
const data = overview.data
const caps = useCapabilities()
const settings = useSettings()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const sealedReady = !!data?.limit?.sealed_ready
const isSealedDegrade = !hasDepth || !sealedReady
// none 档(无 key / 无效 key)→ 显示升级提示横幅
const isNoKey = settings.data?.mode === 'none'
// 无本地数据(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
// 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感
const handleRefresh = () => {
setManualFetching(true)
overview.refetch().finally(() => setManualFetching(false))
}
if (overview.isLoading && !data) {
return (
<div className="flex h-full items-center justify-center bg-base">
<div className="flex items-center gap-2 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</div>
)
}
if (!data) {
return (
<div className="flex h-full items-center justify-center bg-base p-6">
<div className="rounded-card border border-border bg-surface p-6 text-center">
<div className="text-sm text-danger"></div>
<button onClick={() => overview.refetch()} className="mt-3 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-base"></button>
</div>
</div>
)
}
const score = data.emotion?.score ?? 50
const strongUp = data.breadth.strong_up ?? 0
const strongDown = data.breadth.strong_down ?? 0
const latestDate = dataStatus.data?.enriched?.latest_date ?? null
const currentDate = selectedDate ?? data.as_of ?? ''
const quoteRunning = (!selectedDate || selectedDate === latestDate) && data.quote_status?.running
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>
)}
<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" />
<h1 className="text-base font-semibold text-foreground"></h1>
<span
className="rounded-full border px-2 py-0.5 text-[10px] font-medium"
style={{
color: scoreColor(score),
borderColor: `${scoreColor(score)}40`,
background: `${scoreColor(score)}14`,
}}
>
{data.emotion.label} · {score}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-muted">
{currentDate ? (
<DatePicker
value={currentDate}
onChange={setSelectedDate}
min={dataStatus.data?.enriched?.earliest_date ?? undefined}
max={latestDate ?? undefined}
className="w-32"
/>
) : (
<span className="font-mono text-secondary"></span>
)}
<span className="flex items-center gap-1"><Timer className="h-3 w-3" />{quoteAge(data.quote_status?.quote_age_ms)}</span>
<span className={quoteRunning ? 'text-accent' : 'text-warning'}>{quoteRunning ? '实时' : '非实时'}</span>
<button
onClick={handleRefresh}
disabled={manualFetching}
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"
>
<RefreshCw className={`h-3 w-3 ${manualFetching ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
<div className="mb-3 grid grid-cols-4 gap-2">
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
</div>
<div className="mb-3 grid grid-cols-6 gap-2">
<KpiCell label="个股涨 / 平 / 跌" value={<><span className="text-bull">{data.breadth.up}</span><span className="text-muted">/</span><span className="text-muted">{data.breadth.flat}</span><span className="text-muted">/</span><span className="text-bear">{data.breadth.down}</span></>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
<KpiCell label="强势 / 弱势" value={<><span className="text-bull">{strongUp}</span><span className="text-muted">/</span><span className="text-bear">{strongDown}</span></>} sub="涨跌 ≥3%" />
<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" />
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-[minmax(0,1fr)_20rem]">
<main className="min-w-0 space-y-3">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<section className="rounded-card border border-border bg-surface/80 p-2.5">
<SectionTitle icon={BarChart3} title="涨跌分布 / 广度" hint={`${data.breadth.total}`} />
<DistributionBars rows={data.distribution} />
<div className="mt-2">
<BreadthBar data={data.breadth} />
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5">
<MiniMetric label="平均涨跌" value={fmtStockPct(data.breadth.avg_pct)} cls={pctClass(data.breadth.avg_pct)} />
<MiniMetric label="中位涨跌" value={fmtStockPct(data.breadth.median_pct)} cls={pctClass(data.breadth.median_pct)} />
</div>
</section>
<section
className="rounded-card border bg-surface/80 p-2.5"
style={{ borderColor: `${scoreColor(score)}40` }}
>
<SectionTitle icon={Sparkles} title="情绪雷达" hint={`情绪评分 ${score}`} />
<EmotionRadar radar={data.radar} score={score} />
</section>
<section className="flex flex-col rounded-card border border-border bg-surface/80 p-2.5">
<div>
<SectionTitle icon={LineChart} title="趋势强度" hint="均线/新高低" />
<div className="grid grid-cols-3 gap-1.5">
<MiniMetric label="站上MA5" value={`${data.trend.above_ma5_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="站上MA20" value={`${data.trend.above_ma20_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="站上MA60" value={`${data.trend.above_ma60_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="60日新高" value={compactCount(data.trend.new_high)} cls="text-bull" />
<MiniMetric label="60日新低" value={compactCount(data.trend.new_low)} cls="text-bear" />
<MiniMetric label="高低比" value={`${data.trend.new_high + data.trend.new_low > 0 ? Math.round(data.trend.new_high / (data.trend.new_high + data.trend.new_low) * 100) : 50}%`} cls={data.trend.new_high >= data.trend.new_low ? 'text-bull' : 'text-bear'} />
</div>
</div>
<div className="mt-3 border-t border-border pt-2.5">
<SectionTitle icon={Target} title="实用监控" hint="盘中观察" />
<div className="grid grid-cols-3 gap-1.5">
<MiniMetric label="炸板" value={`${data.limit.broken ?? 0}`} cls="text-warning" />
<MiniMetric label="跌停" value={`${data.limit.limit_down ?? 0}`} cls="text-bear" />
<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" />
</div>
</div>
</section>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<HotRankCard title="概念热度" rank={data.concept_rank} configUrl="/concept-analysis" />
<HotRankCard title="行业热度" rank={data.industry_rank} configUrl="/industry-analysis" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StockList title="涨幅榜" rows={data.top_gainers} mode="gain" />
<StockList title="跌幅榜" rows={data.top_losers} mode="loss" />
<StockList title="成交额榜" rows={data.turnover_leaders} mode="amount" />
<StockList title="活跃换手" rows={data.active_leaders} mode="active" />
</div>
</main>
<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>} />
<LadderMini limit={data.limit} />
</section>
<section className="rounded-card border border-border bg-surface/80 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<BellRing className="h-3.5 w-3.5 text-accent" />
<h2 className="text-xs font-semibold text-foreground"></h2>
<span className="font-mono text-[10px] text-muted"></span>
</div>
<Link to="/monitor" className="inline-flex items-center justify-center h-5 w-5 rounded text-muted hover:text-accent hover:bg-accent/10 transition-colors" title="进入监控中心">
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</div>
<MonitorWidget />
</section>
</aside>
</div>
</div>
)
}
+977
View File
@@ -0,0 +1,977 @@
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 {
Database,
Play,
Loader2,
HardDrive,
Clock,
Calendar,
Trash2,
Plus,
AlertTriangle,
Info,
} from 'lucide-react'
import { api, type ExtDataConfig } from '@/lib/api'
import {
useCapabilities,
useSettings,
usePreferences,
useQuoteStatus,
useQuoteInterval,
useDataStatus,
} from '@/lib/useSharedQueries'
import { useToggleRealtimeQuotes, useUpdateQuoteInterval } from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
// 拆分出的子组件
import { StatCard, type FieldTab } from '@/components/data/StatCard'
import { ActiveJobCard } from '@/components/data/ActiveJobCard'
import { SectionTitle, HistoryRow } from '@/components/data/SectionTitle'
import { SettingsModal } from '@/components/data/SettingsModal'
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 { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
import { Skeleton } from '@/components/data/Skeleton'
import { ExtDataStatCard } from '@/components/ext-data/ExtDataStatCard'
import { CreateExtDialog } from '@/components/ext-data/CreateExtDialog'
import { EditExtDialog } from '@/components/ext-data/EditExtDialog'
export function Data() {
const qc = useQueryClient()
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const startTime = useRef<number | null>(null)
const topRef = useRef<HTMLDivElement>(null)
const caps = useCapabilities()
const settings = useSettings()
const status = useDataStatus({
refetchInterval: activeJobId ? 2_000 : 30_000,
})
const history = useQuery({
queryKey: QK.pipelineJobs,
queryFn: () => api.pipelineJobs(15),
refetchInterval: activeJobId ? false : 60_000,
})
const job = useQuery({
queryKey: QK.pipelineJob(activeJobId ?? ''),
queryFn: () => api.pipelineJob(activeJobId!),
enabled: !!activeJobId,
refetchInterval: (q: any) => {
const j = q.state.data
return j && (j.status === 'succeeded' || j.status === 'failed') ? false : 1_000
},
})
const startSync = useMutation({
mutationFn: api.pipelineRun,
onSuccess: ({ job_id }) => {
setActiveJobId(job_id)
startTime.current = Date.now()
},
})
const [showClearConfirm, setShowClearConfirm] = useState(false)
const clearData = useMutation({
mutationFn: api.dataClear,
onSuccess: () => {
qc.invalidateQueries()
setShowClearConfirm(false)
},
})
const updateSchedule = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updatePipelineSchedule(hour, minute),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.dataStatus })
setShowScheduleEdit(false)
},
})
const updateInstSchedule = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updateInstrumentsSchedule(hour, minute),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.dataStatus })
setShowInstScheduleEdit(false)
},
})
const [openSettings, setOpenSettings] = useState<string | null>(null)
const [showScheduleEdit, setShowScheduleEdit] = useState(false)
const [showInstScheduleEdit, setShowInstScheduleEdit] = useState(false)
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 [showCreateExt, setShowCreateExt] = useState(false)
const [editingExt, setEditingExt] = useState<ExtDataConfig | null>(null)
const [indexBatchInput, setIndexBatchInput] = useState('100')
const extConfigs = useQuery({
queryKey: QK.extData,
queryFn: api.extDataList,
})
const deleteExt = useMutation({
mutationFn: (id: string) => api.extDataDelete(id),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.extData }),
})
const syncIndexDaily = useMutation({
mutationFn: () => api.syncIndexDaily(indexSyncDays),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] })
},
})
const prefs = usePreferences()
const minuteAuto = prefs.data?.minute_sync_enabled ?? false
const pipelineSched = prefs.data?.pipeline_schedule ?? { hour: 15, minute: 30 }
const instrumentsSched = prefs.data?.instruments_schedule ?? { hour: 9, minute: 10 }
const indexDailyBatchSize = prefs.data?.index_daily_batch_size ?? 100
useEffect(() => {
setIndexBatchInput(String(indexDailyBatchSize))
}, [indexDailyBatchSize])
const updateIndexBatchSize = useMutation({
mutationFn: (size: number) => api.updateIndexDailyBatchSize(size),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const [showIntervalEdit, setShowIntervalEdit] = useState(false)
const handleToggleIntervalEdit = useCallback((fromEvent?: boolean) => {
setShowIntervalEdit(v => {
const next = !v
if (!fromEvent) {
window.dispatchEvent(new CustomEvent('quote-interval-editor-toggle', { detail: { source: 'data' } }))
}
return next
})
}, [])
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent
if (ce.detail?.source !== 'data') {
setShowIntervalEdit(v => !v)
}
}
window.addEventListener('quote-interval-editor-toggle', handler)
return () => window.removeEventListener('quote-interval-editor-toggle', handler)
}, [])
const quoteInterval = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const realtimeEnabled = prefs.data?.realtime_quotes_enabled ?? false
const quoteStatus = useQuoteStatus()
const toggleQuote = useToggleRealtimeQuotes()
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'] : [])]
useEffect(() => {
if (job.data && (job.data.status === 'succeeded' || job.data.status === 'failed')) {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
const t = setTimeout(() => setActiveJobId(null), 5_000)
return () => clearTimeout(t)
}
}, [job.data?.status])
useEffect(() => {
if (job.isError && /404/.test(String((job.error as any)?.message ?? ''))) {
setActiveJobId(null)
}
}, [job.isError, job.error])
useEffect(() => {
if (!activeJobId && history.data?.active_id) {
setActiveJobId(history.data.active_id)
}
}, [history.data?.active_id])
const s = status.data
const isLoading = status.isLoading
const isRunning = job.data?.status === 'running' || job.data?.status === 'pending'
const isStarting = startSync.isPending
const hasData = !!(s?.instruments?.rows || s?.daily?.rows)
// none 档(无 key / 无效 key) → 禁用立即同步 (同步依赖付费档的批量端点)
const isNoKey = settings.data?.mode === 'none'
const indexOverviewStats = s ? {
rows: 0,
earliest_date: s.index_daily?.earliest_date ?? s.index_enriched?.earliest_date ?? null,
latest_date: s.index_daily?.latest_date ?? s.index_enriched?.latest_date ?? null,
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
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
const indexTargetDate = (() => {
const d = indexEarliestDate ? new Date(indexEarliestDate) : new Date()
d.setDate(d.getDate() - indexOffsetDays)
return d
})()
const indexTargetDateText = indexTargetDate.toISOString().slice(0, 10)
const indexSyncDays = Math.min(
5000,
Math.max(30, Math.ceil((Date.now() - indexTargetDate.getTime()) / 86_400_000) + 1),
)
const STAGE_CARD: Record<string, string> = {
sync_instruments: 'instruments',
sync_daily: 'daily',
extend_history: 'daily',
sync_adj: 'adj_factor',
compute_enriched: 'enriched',
rebuild_enriched: 'enriched',
sync_index: 'index_daily',
sync_minute: 'minute',
extend_minute: 'minute',
}
const activeCard = isRunning && job.data ? STAGE_CARD[job.data.stage] ?? null : null
const skippedCards = new Set(
(job.data?.result?.skipped_stages ?? [])
.map(s => STAGE_CARD[s])
.filter(Boolean) as string[]
)
const prevStageRef = useRef<string | null>(null)
const [doneStages, setDoneStages] = useState<Set<string>>(new Set())
useEffect(() => {
if (!job.data?.stage) return
const stage = job.data.stage
if (stage === prevStageRef.current) return
const prev = prevStageRef.current
if (prev && STAGE_CARD[prev]) {
setDoneStages((s) => new Set(s).add(STAGE_CARD[prev]))
}
prevStageRef.current = stage
qc.invalidateQueries({ queryKey: QK.dataStatus })
}, [job.data?.stage])
useEffect(() => {
if (!activeJobId) {
setDoneStages(new Set())
prevStageRef.current = null
}
}, [activeJobId])
const handleJobClick = useCallback((id: string) => {
setActiveJobId(id)
requestAnimationFrame(() => {
topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, [])
return (
<>
<div ref={topRef} />
<PageHeader
title="数据"
subtitle="本地数据画像 · 同步状态 · 历史记录"
right={
<div className="flex items-center gap-3">
{!hasData && !isLoading && !isNoKey && (
<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"
>
<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>
)}
<div className="w-px h-4 bg-border" />
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowCreateExt(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"
>
<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"
>
<Wifi className="h-3.5 w-3.5" />
测试端点
</button>
*/}
<button
onClick={() => setShowClearConfirm(true)}
disabled={isRunning}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-muted hover:text-danger hover:bg-danger/8 text-xs transition-colors duration-150 disabled:opacity-40 disabled:pointer-events-none"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
}
/>
<div className="px-8 py-6 space-y-6 max-w-6xl">
{/* 未配置 API Key 告警条 —— 引导用户去配置 Key 后才能同步 */}
{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" />
<span className="text-secondary leading-relaxed">
API Key,,
<Link to="/settings?tab=account" className="mx-0.5 font-medium text-warning hover:underline">
</Link>
</span>
</div>
)}
{/* 实时进度 */}
<AnimatePresence>
{job.data && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<ActiveJobCard job={job.data} />
</motion.div>
)}
</AnimatePresence>
{/* 实时行情 + 存储 + 调度 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<QuoteConfigCard
enabled={realtimeEnabled}
running={quoteStatus.data?.running ?? false}
isTrading={quoteStatus.data?.is_trading_hours ?? false}
lastFetchMs={quoteStatus.data?.last_fetch_ms ?? null}
intervalS={quoteInterval.data?.interval ?? quoteStatus.data?.interval_s ?? 10}
intervalMin={quoteInterval.data?.min_interval ?? 5}
intervalMax={quoteInterval.data?.max_interval ?? 60}
loading={quoteStatus.isLoading}
onToggle={(v) => toggleQuote.mutate(v)}
toggling={toggleQuote.isPending}
showIntervalEdit={showIntervalEdit}
onShowIntervalEdit={handleToggleIntervalEdit}
onIntervalChange={(v) => updateInterval.mutate(v)}
/>
{/* 自动调度 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center gap-2 mb-3">
<Calendar className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
{isLoading ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><Skeleton w="w-16" /><Skeleton w="w-28" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-16" /><Skeleton w="w-28" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-6" /><Skeleton w="w-20" /></div>
</div>
) : (
<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 className="text-border"></span>
<span className="text-accent/60 font-medium"></span>
{pipelineSteps.map((step, i) => (
<span key={step} className="contents">
{i > 0 && <span className="text-border"></span>}
<span>{step}</span>
</span>
))}
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-secondary">Asia/Shanghai</span>
</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/50">·</span>
<span className="font-mono text-secondary">
{`${String(instrumentsSched.hour).padStart(2, '0')}:${String(instrumentsSched.minute).padStart(2, '0')}`}
</span>
<button
onClick={() => setShowInstScheduleEdit(v => !v)}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showInstScheduleEdit ? 'text-accent' : 'text-secondary'}`}
>
<Clock className="h-3 w-3" />
</button>
</div>
<div className="flex items-center gap-2 font-mono text-secondary">
{s?.last_instruments_run && (
<span className={`inline-flex flex-col items-center leading-tight ${isToday(s.last_instruments_run) ? 'text-bear' : 'text-secondary/70'}`}>
<span> {formatScheduleDatePart(s.last_instruments_run)}</span>
<span>{formatScheduleTimePart(s.last_instruments_run)}</span>
</span>
)}
{s?.next_instruments_run && (
<span className="inline-flex flex-col items-center leading-tight text-foreground">
<span> {formatScheduleDatePart(s.next_instruments_run)}</span>
<span>{formatScheduleTimePart(s.next_instruments_run)}</span>
</span>
)}
</div>
</div>
<AnimatePresence>
{showInstScheduleEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<ScheduleEditor
value={instrumentsSched}
onSave={(h, m) => updateInstSchedule.mutate({ hour: h, minute: m })}
loading={updateInstSchedule.isPending}
hint="不晚于 09:15"
/>
</motion.div>
)}
</AnimatePresence>
<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/50">·</span>
<span className="font-mono text-secondary">
{`${String(pipelineSched.hour).padStart(2, '0')}:${String(pipelineSched.minute).padStart(2, '0')}`}
</span>
<button
onClick={() => setShowScheduleEdit(v => !v)}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showScheduleEdit ? 'text-accent' : 'text-secondary'}`}
>
<Clock className="h-3 w-3" />
</button>
</div>
<div className="flex items-center gap-2 font-mono text-secondary">
{s?.last_pipeline_run && (
<span className={`inline-flex flex-col items-center leading-tight ${isToday(s.last_pipeline_run) ? 'text-bear' : 'text-secondary/70'}`}>
<span> {formatScheduleDatePart(s.last_pipeline_run)}</span>
<span>{formatScheduleTimePart(s.last_pipeline_run)}</span>
</span>
)}
{s?.next_pipeline_run && (
<span className="inline-flex flex-col items-center leading-tight text-foreground">
<span> {formatScheduleDatePart(s.next_pipeline_run)}</span>
<span>{formatScheduleTimePart(s.next_pipeline_run)}</span>
</span>
)}
</div>
</div>
<AnimatePresence>
{showScheduleEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<ScheduleEditor
value={pipelineSched}
onSave={(h, m) => updateSchedule.mutate({ hour: h, minute: m })}
loading={updateSchedule.isPending}
hint="不早于 15:00"
/>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
{/* 存储 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
{isLoading ? (
<Skeleton w="w-12" />
) : (
<span className="font-mono text-xs text-muted">{s ? `${s.storage.total_size_mb} MB` : '—'}</span>
)}
</div>
<div className="space-y-2">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton w="w-10" />
<div className="flex items-center gap-3">
<Skeleton w="w-14" />
<Skeleton w="w-16" />
</div>
</div>
))
) : [
{ 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 },
{ label: '分钟 K', files: s?.storage.minute_files, size: s?.storage.minute_size_mb },
{ label: '财务数据', files: s?.storage.financials_files, size: s?.storage.financials_size_mb },
].map((item) => (
<div key={item.label} className="flex items-center justify-between text-[11px]">
<span className="text-muted">{item.label}</span>
<div className="flex items-center gap-3">
<span className="font-mono text-secondary">{item.files ?? 0} </span>
<span className="font-mono text-muted w-16 text-right">{(item.size ?? 0).toFixed(1)} MB</span>
</div>
</div>
))}
{/* 扩展数据 */}
{(extConfigs.data && (extConfigs.data.items?.length ?? 0) > 0) && (
<div className="flex items-center justify-between text-[11px] border-t border-border/50 pt-2 mt-1">
<span className="text-muted"></span>
<div className="flex items-center gap-3">
<span className="font-mono text-secondary">{s?.storage.ext_data_files ?? extConfigs.data.items.length} </span>
<span className="font-mono text-muted w-16 text-right">
{s?.storage.ext_data_size_mb != null ? `${s.storage.ext_data_size_mb.toFixed(1)} MB` : '—'}
</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* 数据画像 */}
<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}
/>
{(extConfigs.data?.items ?? []).map((ext) => (
<ExtDataStatCard
key={ext.id}
config={ext}
onDelete={() => deleteExt.mutate(ext.id)}
deleting={deleteExt.isPending}
onEdit={() => setEditingExt(ext)}
/>
))}
</div>
</div>
{/* 同步历史 */}
<div>
<SectionTitle icon={Clock}></SectionTitle>
<div className="mt-3 rounded-card border border-border overflow-hidden">
{history.isLoading ? (
<div className="px-5 py-6 space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Skeleton w="w-4" h="h-4" rounded="rounded-full" />
<div className="space-y-1.5">
<Skeleton w="w-20" />
<Skeleton w="w-28" h="h-3" />
</div>
</div>
<Skeleton w="w-32" />
</div>
))}
</div>
) : history.data && history.data.jobs.length > 0 ? (
<div className="divide-y divide-border">
{history.data.jobs.map((j) => (
<HistoryRow key={j.id} job={j} onClick={() => handleJobClick(j.id)} />
))}
</div>
) : (
<div className="px-5 py-8 text-center text-sm text-muted">
"立即同步"
</div>
)}
</div>
</div>
{startSync.isError && (
<div className="rounded-btn border border-danger/40 bg-danger/5 px-3 py-2 text-sm text-danger">
:{String((startSync.error as any).message)}
</div>
)}
</div>
{/* 弹窗 */}
<EnrichedSchemaModal
table={schemaTable}
onClose={() => setSchemaTable(null)}
/>
{/* 测试端点弹窗已隐藏
{showEndpointTest && (
<EndpointTestDialog
hasKey={settings.data?.mode === 'api_key'}
tierLabel={settings.data?.tier_label ?? ''}
currentEndpoint={settings.data?.current_endpoint ?? ''}
onClose={() => setShowEndpointTest(false)}
/>
)}
*/}
<AnimatePresence>
{showCreateExt && (
<CreateExtDialog onClose={() => setShowCreateExt(false)} />
)}
{editingExt && (
<EditExtDialog config={editingExt} onClose={() => setEditingExt(null)} />
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'daily' && (
<SettingsModal title="日 K · 向前扩展历史" onClose={() => setOpenSettings(null)}>
<ExtendHistoryPanel
caps={caps.data}
isRunning={!!activeJobId}
earliestDate={s?.daily?.earliest_date ?? null}
onStart={() => setOpenSettings(null)}
/>
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'enriched' && (
<SettingsModal title="Enriched · 计算设置" onClose={() => setOpenSettings(null)}>
<EnrichedRebuildPanel isRunning={!!activeJobId} onStart={() => setOpenSettings(null)} />
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'index' && (
<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>
<div className="text-sm font-medium text-foreground"> K</div>
<div className="text-[11px] text-muted mt-1"> CN_Index </div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => setIndexExtendValue(v => Math.max(1, v - 1))}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
></button>
<div className="h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums text-foreground bg-base">
{indexExtendValue}
</div>
<button
onClick={() => setIndexExtendValue(v => Math.min(indexExtendUnit === 'year' ? 10 : 36, v + 1))}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
</div>
<div className="flex rounded-btn border border-border overflow-hidden">
{(['month', 'year'] as const).map(u => (
<button
key={u}
onClick={() => { setIndexExtendUnit(u); if (u === 'year' && indexExtendValue > 10) setIndexExtendValue(1); if (u === 'month' && indexExtendValue > 36) setIndexExtendValue(6) }}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className={`px-2 py-0.5 text-[10px] font-medium transition-colors disabled:opacity-40 ${
indexExtendUnit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
}`}
>{u === 'month' ? '月' : '年'}</button>
))}
</div>
</div>
<div className="text-[10px] text-muted">
<span className="font-mono text-secondary">{indexTargetDateText}</span>
{indexEarliestDate && <span> (: <span className="font-mono text-secondary">{indexEarliestDate}</span>)</span>}
</div>
<div className="rounded-btn border border-border bg-base/40 p-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-medium text-foreground"></div>
<div className="text-[10px] text-muted mt-0.5"> 100</div>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
max={10000}
value={indexBatchInput}
onChange={e => setIndexBatchInput(e.target.value)}
disabled={updateIndexBatchSize.isPending || !!activeJobId || syncIndexDaily.isPending}
className="w-20 px-2 py-1 rounded-btn bg-elevated border border-border text-xs font-mono text-foreground outline-none focus:border-accent disabled:opacity-40"
/>
<button
onClick={() => {
const size = Math.max(1, Math.min(10000, Number(indexBatchInput) || 100))
setIndexBatchInput(String(size))
updateIndexBatchSize.mutate(size)
}}
disabled={updateIndexBatchSize.isPending || !!activeJobId || syncIndexDaily.isPending}
className="px-2.5 py-1 rounded-btn bg-elevated border border-border text-xs text-secondary hover:text-foreground disabled:opacity-40 transition-colors"
>
{updateIndexBatchSize.isPending ? '保存中…' : '保存'}
</button>
</div>
</div>
<div className="text-[10px] text-muted">
: <span className="font-mono text-secondary">{indexDailyBatchSize}</span>
</div>
</div>
<button
onClick={() => syncIndexDaily.mutate()}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
>
{syncIndexDaily.isPending ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
</>
) : (
<></>
)}
</button>
{!hasDailyBatchCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
Starter+ / Pro K
</span>
)}
</div>
</div>
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'minute' && (
<SettingsModal title="分钟 K · 同步设置" onClose={() => setOpenSettings(null)}>
<MinuteSyncConfig caps={caps.data} isRunning={!!activeJobId} onStart={() => setOpenSettings(null)} />
</SettingsModal>
)}
</AnimatePresence>
{/* 清除数据二次确认弹窗 */}
<AnimatePresence>
{showClearConfirm && (
<div className="fixed inset-0 z-50 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/60 backdrop-blur-sm"
onClick={() => !clearData.isPending && setShowClearConfirm(false)}
/>
<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-[420px] 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-danger/12 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground mb-1.5"></h3>
<p className="text-xs text-secondary leading-relaxed">
<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>· Enriched K</li>
<li>· </li>
</ul>
<p className="mt-2 text-[11px] text-danger/90">
</p>
<div className="mt-2 flex items-start gap-1.5 text-[11px] text-warning">
<Info className="h-3.5 w-3.5 shrink-0 mt-px text-warning" />
<span></span>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
onClick={() => setShowClearConfirm(false)}
disabled={clearData.isPending}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors disabled:opacity-50"
>
</button>
<button
onClick={() => clearData.mutate()}
disabled={clearData.isPending}
className="px-3 py-1.5 rounded-btn bg-danger/90 text-base text-sm font-medium hover:bg-danger disabled:opacity-50 transition-colors"
>
{clearData.isPending ? '清除中…' : '清除数据'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
)
}
+357
View File
@@ -0,0 +1,357 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, Search, AlertTriangle, CheckCircle2, XCircle, FlaskConical, Activity } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { resetBadge } from '@/lib/monitorBadge'
// ── 分钟K探测 (迁移自 MinuteDataProbe) ─────────────────
interface ProbeResult {
date: string
rows: number
source: string
ok: boolean
}
function MinuteProbePanel() {
const [symbol, setSymbol] = useState('603261.SH')
const [days, setDays] = useState(10)
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<ProbeResult[]>([])
const [error, setError] = useState<string | null>(null)
const runProbe = async () => {
const sym = symbol.trim().toUpperCase()
if (!sym) return
setLoading(true)
setError(null)
setResults([])
const dates: string[] = []
const today = new Date()
for (let i = 0; i < days; i++) {
const d = new Date(today)
d.setDate(d.getDate() - i)
dates.push(d.toISOString().slice(0, 10))
}
const out: ProbeResult[] = []
try {
for (const date of dates) {
const r = await api.klineMinute(sym, date)
const rows = r.rows?.length ?? 0
out.push({
date,
rows,
source: r.source ?? (rows > 0 ? 'local' : 'none'),
ok: rows > 0,
})
setResults([...out])
}
} catch (e: any) {
setError(e?.message ?? String(e))
} finally {
setLoading(false)
}
}
const total = results.length
const hasData = results.filter((r) => r.ok).length
const missing = results.filter((r) => !r.ok)
return (
<div className="space-y-4">
<div>
<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数据是否齐全
</p>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-btn bg-elevated p-4">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
placeholder="603261.SH"
className="w-44 rounded-btn border border-border bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
onKeyDown={(e) => e.key === 'Enter' && !loading && runProbe()}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
type="number"
min={1}
max={30}
value={days}
onChange={(e) => setDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
className="w-24 rounded-btn border border-border bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
/>
</div>
<button
onClick={runProbe}
disabled={loading || !symbol.trim()}
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"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{loading ? '探测中…' : '开始探测'}
</button>
</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>
)}
{total > 0 && (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-foreground">{total}</div>
</div>
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-emerald-400">{hasData}</div>
</div>
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-danger">{missing.length}</div>
</div>
</div>
)}
{results.length > 0 && (
<div className="overflow-hidden rounded-btn border border-border">
<table className="w-full text-sm">
<thead className="bg-elevated text-xs text-muted">
<tr>
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-right font-medium">K条数</th>
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.date} className="border-t border-border/60">
<td className="px-4 py-2 text-foreground">{r.date}</td>
<td className="px-4 py-2 text-right tabular-nums text-foreground">{r.rows}</td>
<td className="px-4 py-2 text-secondary">
<span className="rounded bg-elevated px-1.5 py-0.5 text-xs">{r.source}</span>
</td>
<td className="px-4 py-2 text-center">
{r.ok ? (
<span className="inline-flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-danger">
<XCircle className="h-4 w-4" />
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{missing.length > 0 && (
<div className="rounded-btn border border-warning/40 bg-warning/10 p-3 text-xs text-foreground">
<div className="mb-1 flex items-center gap-1.5 font-medium text-warning">
<AlertTriangle className="h-4 w-4" />
</div>
<p className="leading-relaxed text-secondary">
<span className="text-foreground">/</span>
<span className="text-foreground"></span> 0
<span className="text-foreground"></span>K有成交量K
</p>
</div>
)}
</div>
)
}
// ── 演示数据生成 ──────────────────────────────────────
function SeedPanel() {
const qc = useQueryClient()
const [count, setCount] = useState(12)
const [recent, setRecent] = useState(true)
const [msg, setMsg] = useState('')
const seedMut = useMutation({
mutationFn: () => api.alertSeed(count, recent),
onSuccess: (data) => {
setMsg(`已生成 ${data.generated} 条触发记录`)
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
setTimeout(() => setMsg(''), 4000)
},
onError: () => {
setMsg('生成失败')
setTimeout(() => setMsg(''), 4000)
},
})
const clearMut = useMutation({
mutationFn: () => api.alertsClear(),
onSuccess: (data) => {
setMsg(`已清空 ${data.cleared} 条触发记录`)
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
resetBadge()
setTimeout(() => setMsg(''), 4000)
},
})
const ruleSeedMut = useMutation({
mutationFn: () => api.monitorRuleSeed(),
onSuccess: (data) => {
setMsg(`已生成 ${data.generated} 条监控规则`)
qc.invalidateQueries({ queryKey: ['monitor-rules'] })
setTimeout(() => setMsg(''), 4000)
},
onError: () => {
setMsg('规则生成失败')
setTimeout(() => setMsg(''), 4000)
},
})
return (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold text-foreground"></h2>
<p className="mt-1 text-xs text-muted">
,
</p>
</div>
<div className="space-y-3 rounded-btn bg-elevated p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
type="number"
min={1}
max={50}
value={count}
onChange={(e) => setCount(Math.max(1, Math.min(50, Number(e.target.value) || 1)))}
className="w-24 rounded-btn border border-border bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
/>
</div>
<label className="flex items-center gap-1.5 pb-1.5">
<input
type="checkbox"
checked={recent}
onChange={(e) => setRecent(e.target.checked)}
className="h-3.5 w-3.5 accent-accent"
/>
<span className="text-xs text-secondary">"刚刚"()</span>
</label>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => seedMut.mutate()}
disabled={seedMut.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"
>
{seedMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <FlaskConical className="h-4 w-4" />}
</button>
<button
onClick={() => clearMut.mutate()}
disabled={clearMut.isPending}
className="flex items-center gap-1.5 rounded-btn border border-danger/40 bg-danger/10 px-4 py-1.5 text-sm font-medium text-danger hover:bg-danger/20 disabled:opacity-50 cursor-pointer"
>
{clearMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <XCircle className="h-4 w-4" />}
</button>
</div>
</div>
{msg && (
<div className="rounded-btn border border-accent/40 bg-accent/10 p-3 text-sm text-accent">{msg}</div>
)}
{/* 监控规则生成 */}
<div className="space-y-3 rounded-btn bg-elevated p-4">
<div>
<h3 className="text-sm font-medium text-foreground"></h3>
<p className="mt-0.5 text-xs text-muted">
(///),
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => ruleSeedMut.mutate()}
disabled={ruleSeedMut.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"
>
{ruleSeedMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <FlaskConical className="h-4 w-4" />}
</button>
</div>
</div>
<div className="rounded-btn border border-border/40 bg-surface/40 p-4 text-xs leading-relaxed text-muted">
<div className="mb-1 font-medium text-secondary">使</div>
<ul className="list-disc space-y-0.5 pl-4">
<li>,,</li>
<li></li>
<li>///</li>
<li></li>
</ul>
</div>
</div>
)
}
// ── Dev 主页面 ────────────────────────────────────────
export function Dev() {
const [tab, setTab] = useState<'minute' | 'seed'>('seed')
return (
<div className="flex flex-col h-full">
<PageHeader
title="开发者工具"
subtitle="调试与测试 · 不暴露在菜单"
right={
<div className="flex items-center gap-1 rounded-btn bg-elevated p-0.5">
<button
onClick={() => setTab('seed')}
className={cn(
'inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer',
tab === 'seed' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary',
)}
>
<FlaskConical className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setTab('minute')}
className={cn(
'inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer',
tab === 'minute' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary',
)}
>
<Activity className="h-3.5 w-3.5" />K探测
</button>
</div>
}
/>
<div className="flex-1 overflow-auto px-5 py-4">
<div className="mx-auto max-w-3xl space-y-4">
{tab === 'minute' ? <MinuteProbePanel /> : <SeedPanel />}
</div>
</div>
</div>
)
}
+273
View File
@@ -0,0 +1,273 @@
import { useState, useRef } from 'react'
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass } 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 { fmtBigNum } from '@/lib/format'
const TABLE_LABELS: Record<string, string> = {
metrics: '核心指标',
income: '利润表',
balance_sheet: '资产负债表',
cash_flow: '现金流量表',
}
const TABLE_ICON: Record<string, typeof FileText> = {
metrics: Database,
income: FileText,
balance_sheet: FileText,
cash_flow: FileText,
}
export function Financials() {
const { data: caps } = useCapabilities()
const hasFinancial = caps?.capabilities?.['financial'] != null
const { data: status, isLoading } = useFinancialStatus()
const syncMut = useFinancialSync()
// 同步状态: 服务端 syncing 真值优先, 兜底本地 mutation pending
const syncing = (status?.syncing ?? false) || syncMut.isPending
// 本次同步开始时间戳(ms): 用于判断每张表的 last_sync 是否属于本次同步
// (后端每张表完成即更新 last_sync, 前端轮询时对比时间戳得到精确进度)
const syncStartedAtRef = useRef<number | null>(null)
// 单表同步时记录表名 (null = 全量同步), 用于区分卡片状态
const syncSingleTableRef = useRef<string | null>(null)
// 选中的个股(模糊搜索结果);null 时显示搜索引导
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
if (!hasFinancial) {
return (
<>
<PageHeader title="财务" subtitle="利润表 / 资负表 / 现金流 / 关键指标 · 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">
<Lock className="h-6 w-6 text-warning" />
</div>
<h3 className="mt-4 text-base font-semibold text-foreground"> Expert </h3>
<p className="mt-2 text-xs leading-relaxed text-secondary">
Expert
</p>
</div>
</div>
</>
)
}
const handleSync = (table: string) => {
// 防重复点击:syncing 中不再触发(后端 run_now 也有 is_syncing 兜底)
if (syncing) return
// 记录开始时间: 全量同步判断所有 4 张表, 单表同步只判断这一张
syncStartedAtRef.current = Date.now()
syncSingleTableRef.current = table === 'all' ? null : table
syncMut.mutate(table, {
onSettled: () => {
syncStartedAtRef.current = null
syncSingleTableRef.current = null
},
})
}
const tables = status?.tables ?? {}
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
const tableDoneThisRound = (key: string): boolean => {
if (!syncStartedAt || !syncing) return false
// 单表同步: 只判断这一张表是否完成
if (syncSingleTable && key !== syncSingleTable) return false
const ls = lastSync[key]
if (!ls) return false
return new Date(ls).getTime() >= syncStartedAt
}
// 当前正在同步的表:
// 全量同步 → 第一个未完成的; 单表同步 → 那张表(未完成时)
const currentSyncingTable = syncing && syncStartedAt
? (syncSingleTable
? (tableDoneThisRound(syncSingleTable) ? null : syncSingleTable)
: TABLE_ORDER.find(t => !tableDoneThisRound(t)) ?? null)
: null
const syncedCount = TABLE_ORDER.filter(t => tableDoneThisRound(t)).length
// 卡片三态: 仅全量同步时未轮到的表显示"等待"; 单表同步时其他表保持原样
const isWaitingTable = (key: string): boolean =>
!!isFullSync && !tableDoneThisRound(key) && currentSyncingTable !== key
return (
<>
<PageHeader
title="财务"
subtitle="利润表 / 资负表 / 现金流 / 关键指标 · Expert"
right={
<div className="flex items-center gap-2">
{syncing && (
<span className="text-xs text-accent/80 flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" />
{isFullSync
? `已同步 ${syncedCount}/4 张表…`
: isSingleSync
? `同步${TABLE_LABELS[syncSingleTable!] ?? syncSingleTable}`
: '同步中…'}
</span>
)}
<button
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 transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => handleSync('all')}
disabled={syncing}
title={syncing ? '正在同步,请稍候…' : '同步全部财务表'}
>
{syncing
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
{syncing ? '同步中…' : '全部同步'}
</button>
</div>
}
/>
<div className="px-8 py-6 space-y-6 max-w-7xl">
{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" />
</div>
)}
{/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */}
{!isLoading && available && (
<div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(TABLE_LABELS).map(([key, label]) => {
const info = tables[key]
const TIcon = TABLE_ICON[key] ?? Database
const hasData = (info?.rows ?? 0) > 0
// 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待")
const doneThisRound = tableDoneThisRound(key)
const isThisSyncing = currentSyncingTable === key
const isWaiting = isWaitingTable(key)
const lsTime = lastSync[key]
return (
<div
key={key}
className={`rounded-card border p-3.5 transition-colors flex flex-col ${
isThisSyncing
? 'border-accent/40 bg-accent/[0.04]'
: isWaiting
? 'border-border/50 bg-elevated/15'
: hasData
? 'border-border bg-surface'
: 'border-dashed border-border/60 bg-elevated/20'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{doneThisRound ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
) : isThisSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />
) : isWaiting ? (
<Hourglass className="h-3.5 w-3.5 text-muted/60" />
) : (
<TIcon className={`h-3.5 w-3.5 ${hasData ? 'text-accent' : 'text-muted'}`} />
)}
<span className="text-xs font-medium text-foreground">{label}</span>
</div>
<button
className="text-muted hover:text-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => handleSync(key)}
disabled={syncing}
title={syncing ? '正在同步…' : `同步${label}`}
>
{syncing
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
</button>
</div>
<div className="mt-2 text-xl font-semibold tabular-nums text-foreground">
{fmtBigNum(info?.rows ?? 0)}
<span className="text-[10px] text-muted ml-1 font-normal"></span>
</div>
<div className="text-[11px] text-muted mt-0.5">
{fmtBigNum(info?.symbols ?? 0)}
</div>
<div className="mt-auto pt-2 border-t border-border/40 text-[10px] text-muted flex items-center gap-1">
<Clock className="h-2.5 w-2.5 shrink-0" />
{lsTime
? new Date(lsTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: '尚未同步'}
</div>
</div>
)
})}
</div>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted" />
</div>
) : !available ? (
<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>
) : (
<>
{/* 个股搜索区 */}
<div>
{selected ? (
// 已选股:紧凑搜索条 + 清除按钮(便于换股)
<div className="flex items-center gap-3">
<div className="flex-1 max-w-xl">
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
</div>
<button
onClick={() => setSelected(null)}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-secondary hover:text-foreground rounded-btn border border-border hover:bg-elevated transition-colors shrink-0"
title="清除选择"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
// 未选股:醒目居中引导
<div className="flex flex-col items-center gap-3 py-8">
<div className="flex items-center gap-2 text-sm text-secondary">
<Search className="h-4 w-4 text-accent" />
<span></span>
</div>
<div className="w-full max-w-xl">
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
</div>
<div className="text-[11px] text-muted"> 600000 / </div>
</div>
)}
</div>
{/* 个股详情 / 空引导 */}
<div className="pb-4">
{selected ? (
<StockFinancialDetail symbol={selected.symbol} name={selected.name} />
) : (
<EmptyState
icon={Search}
title="未选择股票"
hint="在上方搜索框输入股票代码或名称,选择后即可查看该股的核心指标、利润表、资产负债表与现金流量表。"
/>
)}
</div>
</>
)}
</div>
</>
)
}
+350
View File
@@ -0,0 +1,350 @@
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Activity, Loader2, Lock, RefreshCw, Search } from 'lucide-react'
import { api, type IndexInstrument, type KlineRow, type MinuteKlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useCapabilities } from '@/lib/useSharedQueries'
import { EChartsCandlestick, type OHLC } from '@/components/EChartsCandlestick'
import { EChartsIntraday } from '@/components/EChartsIntraday'
function defaultRange() {
const now = new Date()
const end = now.toISOString().slice(0, 10)
const s = new Date(now)
s.setMonth(s.getMonth() - 6)
return { start: s.toISOString().slice(0, 10), end }
}
function toOHLC(rows: KlineRow[]): OHLC[] {
return rows
.filter(r => r?.date != null && r.open != null && r.close != null)
.map(r => ({
date: typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date),
open: Number(r.open),
high: Number(r.high),
low: Number(r.low),
close: Number(r.close),
volume: Number(r.volume ?? 0),
ma5: r.ma5 != null ? Number(r.ma5) : null,
ma10: r.ma10 != null ? Number(r.ma10) : null,
ma20: r.ma20 != null ? Number(r.ma20) : null,
ma60: r.ma60 != null ? Number(r.ma60) : null,
macd_dif: r.macd_dif != null ? Number(r.macd_dif) : null,
macd_dea: r.macd_dea != null ? Number(r.macd_dea) : null,
macd_hist: r.macd_hist != null ? Number(r.macd_hist) : null,
rsi_6: r.rsi_6 != null ? Number(r.rsi_6) : null,
rsi_14: r.rsi_14 != null ? Number(r.rsi_14) : null,
rsi_24: r.rsi_24 != null ? Number(r.rsi_24) : null,
kdj_k: r.kdj_k != null ? Number(r.kdj_k) : null,
kdj_d: r.kdj_d != null ? Number(r.kdj_d) : null,
kdj_j: r.kdj_j != null ? Number(r.kdj_j) : null,
boll_upper: r.boll_upper != null ? Number(r.boll_upper) : null,
boll_lower: r.boll_lower != null ? Number(r.boll_lower) : null,
}))
}
function fmtPct(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return `${Number(v).toFixed(2)}%`
}
function fmtNum(v: number | null | undefined, digits = 2) {
if (v == null || Number.isNaN(Number(v))) return '--'
return Number(v).toFixed(digits)
}
const PINNED_INDEXES = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
]
function pinnedRank(item: IndexInstrument) {
return PINNED_INDEXES.findIndex(p => item.symbol === p.symbol || item.name === p.name)
}
export function Indices() {
const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const [keyword, setKeyword] = useState('')
const symbolParam = searchParams.get('symbol') ?? ''
const [selected, setSelected] = useState<string>(symbolParam)
const [range, setRange] = useState(defaultRange)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
// 分时数据需 Pro+ (kline.minute.batch) 能力
const caps = useCapabilities()
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
const list = useQuery({
queryKey: QK.indexList,
queryFn: api.indexList,
})
const search = useQuery({
queryKey: ['index-search', keyword],
queryFn: () => api.indexSearch(keyword, 50),
enabled: keyword.trim().length > 0,
})
const rows: IndexInstrument[] = keyword.trim()
? (search.data?.results ?? [])
: (list.data?.results ?? [])
const topRows = useMemo(() => {
const all = list.data?.results ?? []
return PINNED_INDEXES.map(p => (
all.find(item => item.symbol === p.symbol || item.name === p.name) ?? { symbol: p.symbol, name: p.name, asset_type: 'index' as const }
))
}, [list.data?.results])
const listRows = useMemo(() => rows.filter(item => pinnedRank(item) < 0), [rows])
const selectedSymbol = selected || topRows[0]?.symbol || listRows[0]?.symbol || ''
useEffect(() => {
if (symbolParam && symbolParam !== selected) setSelected(symbolParam)
}, [selected, symbolParam])
const selectIndex = (symbol: string) => {
setSelected(symbol)
setSearchParams({ symbol })
}
const quotes = useQuery({
queryKey: QK.indexQuotes,
queryFn: () => api.indexQuotes(),
placeholderData: (prev) => prev,
})
const daily = useQuery({
queryKey: QK.indexDaily(selectedSymbol, range.start, range.end),
queryFn: () => api.indexDaily(selectedSymbol, 180, range),
enabled: !!selectedSymbol,
placeholderData: (prev) => prev,
})
const minute = useQuery({
queryKey: QK.indexMinute(selectedSymbol, selectedDate ?? ''),
queryFn: () => api.indexMinute(selectedSymbol, selectedDate ?? undefined),
enabled: !!selectedSymbol && !!selectedDate && hasMinuteCap,
placeholderData: (prev) => prev,
})
const syncInstruments = useMutation({
mutationFn: api.syncIndexInstruments,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
},
})
const syncDaily = useMutation({
mutationFn: () => api.syncIndexDaily(365),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] })
},
})
const quoteBySymbol = useMemo(() => {
const m = new Map<string, any>()
for (const q of quotes.data?.rows ?? []) m.set(q.symbol, q)
return m
}, [quotes.data?.rows])
const selectedQuote = selectedSymbol ? quoteBySymbol.get(selectedSymbol) : null
const selectedQuoteValue = selectedQuote?.last_price ?? selectedQuote?.price ?? selectedQuote?.close
const selectedQuotePct = selectedQuote?.change_pct ?? selectedQuote?.pct
const chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows])
const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info
const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? []
const selectedIdx = selectedDate ? chartRows.findIndex(r => r.date === selectedDate) : -1
const prevClose = selectedIdx > 0
? chartRows[selectedIdx - 1].close
: chartRows.length >= 2
? chartRows[chartRows.length - 2].close
: undefined
useEffect(() => {
setSelectedDate(null)
setLinkedPrice(null)
}, [selectedSymbol])
useEffect(() => {
if ((!selectedDate || !chartRows.some(r => r.date === selectedDate)) && chartRows.length > 0 && daily.data?.symbol === selectedSymbol) {
setSelectedDate(chartRows[chartRows.length - 1].date)
}
}, [chartRows, daily.data?.symbol, selectedDate, selectedSymbol])
const renderIndexItem = (item: IndexInstrument) => {
const q = quoteBySymbol.get(item.symbol)
const pct = q?.change_pct ?? q?.pct
const current = q?.last_price ?? q?.price ?? q?.close
const active = item.symbol === selectedSymbol
return (
<button
key={item.symbol}
onClick={() => selectIndex(item.symbol)}
className={`w-full rounded-btn px-2 py-2 text-left transition-colors ${active ? 'bg-accent/15 text-foreground' : 'hover:bg-elevated text-secondary'}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium">{item.name || item.symbol}</span>
<span className={`text-[10px] font-mono ${Number(pct ?? 0) >= 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(pct)}</span>
</div>
<div className="mt-0.5 flex items-center justify-between text-[10px] font-mono text-muted">
<span>{item.symbol}</span>
<span>{fmtNum(current)}</span>
</div>
</button>
)
}
return (
<div className="h-full overflow-auto bg-base p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground"></h1>
<p className="mt-1 text-xs text-muted">
使 kline_index_* parquet
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => syncInstruments.mutate()}
disabled={syncInstruments.isPending}
className="inline-flex items-center gap-1.5 rounded-btn bg-elevated px-3 py-1.5 text-xs text-secondary hover:text-foreground disabled:opacity-50"
>
{syncInstruments.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => syncDaily.mutate()}
disabled={syncDaily.isPending}
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-base hover:bg-accent/90 disabled:opacity-50"
>
{syncDaily.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
K
</button>
</div>
</div>
<div className="grid grid-cols-[15rem_1fr] gap-4">
<aside className="rounded-card border border-border bg-surface p-3">
<div className="relative mb-3">
<Search className="pointer-events-none absolute left-2 top-2 h-3.5 w-3.5 text-muted" />
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索指数代码/名称"
className="w-full rounded-btn border border-border bg-base py-1.5 pl-7 pr-2 text-xs text-foreground outline-none focus:border-accent"
/>
</div>
<div className="mb-3 space-y-1 border-b border-border/60 pb-3">
{topRows.map(renderIndexItem)}
</div>
<div className="max-h-[calc(100vh-24rem)] space-y-1 overflow-auto pr-1">
{(list.isLoading || search.isLoading) && <div className="py-4 text-center text-xs text-muted"></div>}
{!list.isLoading && listRows.length === 0 && (
<div className="rounded-btn bg-elevated p-3 text-xs text-muted">
{keyword.trim() ? '无匹配指数。' : '暂无更多指数,先点击“同步指数列表”。'}
</div>
)}
{listRows.map(renderIndexItem)}
</div>
</aside>
<main className="min-w-0 rounded-card border border-border bg-surface p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-accent" />
<h2 className="truncate text-sm font-semibold text-foreground">
{selectedInfo?.name || selectedSymbol || '未选择指数'}
</h2>
{selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>}
{selectedSymbol && <span className="font-mono text-xs text-foreground">{fmtNum(selectedQuoteValue)}</span>}
{selectedSymbol && <span className={`font-mono text-xs ${Number(selectedQuotePct ?? 0) >= 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(selectedQuotePct)}</span>}
</div>
<div className="mt-1 text-xs text-muted">
{quotes.data?.count ?? 0} · K来源 {daily.data?.source ?? '--'}
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<input
type="date"
value={range.start}
onChange={e => setRange(r => ({ ...r, start: e.target.value }))}
className="rounded-btn border border-border bg-base px-2 py-1 text-secondary outline-none focus:border-accent"
/>
<span className="text-muted"></span>
<input
type="date"
value={range.end}
onChange={e => setRange(r => ({ ...r, end: e.target.value }))}
className="rounded-btn border border-border bg-base px-2 py-1 text-secondary outline-none focus:border-accent"
/>
</div>
</div>
{daily.isLoading && <div className="py-10 text-center text-sm text-muted">K加载中</div>}
{daily.isError && <div className="py-4 text-sm text-danger">K加载失败</div>}
{!daily.isLoading && !daily.isError && chartRows.length === 0 && (
<div className="rounded-card bg-elevated p-6 text-center text-sm text-muted">
K数据K
</div>
)}
{chartRows.length > 0 && (
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<EChartsCandlestick
data={chartRows}
height={620}
showMA={true}
showInfoBar={true}
showMarkers={false}
symbol={selectedSymbol}
linkedPrice={linkedPrice}
onDateClick={setSelectedDate}
visibleBars={48}
activeIndicators={['vol', 'macd']}
/>
</div>
<div className="min-w-0 flex-1 border-l border-border pl-3" style={{ height: 620 }}>
{!hasMinuteCap ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<Lock className="h-5 w-5 text-muted" />
<div className="text-xs text-secondary"> Pro+</div>
<div className="text-[10px] text-muted"></div>
</div>
) : (
<>
{minute.isLoading && <div className="py-2 text-xs text-muted"></div>}
{!minute.isLoading && minuteRows.length === 0 && (
<div className="flex h-full items-center justify-center text-xs text-muted">
</div>
)}
{minuteRows.length > 0 && (
<EChartsIntraday
data={minuteRows}
height={620}
prevClose={prevClose}
date={selectedDate ?? undefined}
symbol={selectedSymbol}
showLimitLines={false}
showAvgLine={false}
onPriceHover={setLinkedPrice}
/>
)}
</>
)}
</div>
</div>
)}
</main>
</div>
</div>
)
}
+822
View File
@@ -0,0 +1,822 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
Activity,
Crown,
Database,
Layers3,
RefreshCw,
Search,
Settings2,
TrendingDown,
TrendingUp,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, DimensionHeatmap, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format'
import { cn } from '@/lib/cn'
import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter'
const KEYWORDS = ['industry', '行业', 'sector', '申万', '中信']
const CANDIDATE_FIELDS = ['industry', '行业', 'sector', '申万', '中信', '行业名称', 'industry_name', 'sector_name']
const PAGE_LIMIT = 12000
const MAX_RENDERED_INDUSTRIES = 120
const MAX_RENDERED_STOCKS = 160
type SortMode = 'heat' | 'avgPct' | 'leader' | 'amount' | 'down'
type IndustryLevel = 1 | 2 | 3
interface EnrichedStock extends MarketSnapshotRow {
leaderScore: number
leaderParts: {
momentum: number
turnover: number
amount: number
cap: number
volume: number
boards: number
}
}
interface IndustryStat {
key: string
stocks: EnrichedStock[]
count: number
avgPct: number | null
medianPct: number | null
upCount: number
downCount: number
flatCount: number
upRate: number
strongCount: number
weakCount: number
totalAmount: number
avgTurnover: number | null
avgVolRatio: number | null
leader: EnrichedStock | null
heatScore: number
riskScore: number
}
// ===== 配置持久化 =====
function loadConfig(): AnalysisFieldConfig {
return storage.industryAnalysisConfig.get({}) as AnalysisFieldConfig
}
function saveConfig(c: AnalysisFieldConfig) {
storage.industryAnalysisConfig.set(c)
}
// ===== 自动选取最佳数据源 =====
function pickBestConfig(
configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[],
): string {
let best = '', bestScore = 0
for (const c of configs) {
const haystack = [c.id, c.label, c.description ?? '', ...c.fields.flatMap(f => [f.name, f.label])].join(' ').toLowerCase()
const score = KEYWORDS.reduce((n, k) => n + (haystack.includes(k) ? 1 : 0), 0)
if (score > bestScore) { bestScore = score; best = c.id }
}
return best
}
// ===== 工具函数 =====
function symbolKeys(symbol: unknown): string[] {
const raw = String(symbol ?? '').trim()
if (!raw) return []
const plain = raw.replace(/\.\w+$/, '')
return Array.from(new Set([raw, plain]))
}
function buildMarketMap(rows: MarketSnapshotRow[]) {
const map = new Map<string, MarketSnapshotRow>()
for (const r of rows) {
for (const key of symbolKeys(r.symbol)) map.set(key, r)
}
return map
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function avg(values: number[]) {
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null
}
function median(values: number[]) {
if (!values.length) return null
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
// ===== 龙头算法 =====
function leaderScore(stock: MarketSnapshotRow) {
const pct = num(stock.change_pct) ?? 0
const turnover = num(stock.turnover_rate) ?? 0
const amount = num(stock.amount) ?? 0
const cap = num(stock.float_market_cap) ?? num(stock.market_cap) ?? 0
const volRatio = num(stock.vol_ratio_5d) ?? 1
const boards = num(stock.consecutive_limit_ups) ?? 0
const parts = {
momentum: clamp01((pct + 0.02) / 0.12),
turnover: clamp01(Math.log1p(Math.max(turnover, 0)) / Math.log1p(30)),
amount: clamp01(Math.log1p(Math.max(amount, 0)) / Math.log1p(20_000_000_000)),
cap: clamp01(Math.log1p(Math.max(cap, 0)) / Math.log1p(300_000_000_000)),
volume: clamp01((volRatio - 1) / 4),
boards: clamp01(boards / 5),
}
const score = (
parts.momentum * 0.35 +
parts.turnover * 0.22 +
parts.amount * 0.18 +
parts.cap * 0.15 +
parts.volume * 0.07 +
parts.boards * 0.03
) * 100
return { score, parts }
}
function enrichStock(stock: StockRow, marketMap: Map<string, MarketSnapshotRow>): EnrichedStock {
const market = symbolKeys(stock.symbol ?? stock.code).map(k => marketMap.get(k)).find(Boolean) ?? {}
const merged = { ...stock, ...market } as MarketSnapshotRow & StockRow
const ls = leaderScore(merged)
return {
...merged,
symbol: String(merged.symbol ?? stock.symbol ?? stock.code ?? ''),
name: merged.name ?? String(stock.name ?? stock['股票简称'] ?? ''),
leaderScore: ls.score,
leaderParts: ls.parts,
}
}
// ===== 行业统计计算 =====
function calcIndustryStat(group: DimensionGroup, marketMap: Map<string, MarketSnapshotRow>): IndustryStat {
const seen = new Set<string>()
const stocks = group.stocks
.map(s => enrichStock(s, marketMap))
.filter(s => {
const key = String(s.symbol ?? '')
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
const pctValues = stocks.map(s => num(s.change_pct)).filter((v): v is number => v != null)
const turnoverValues = stocks.map(s => num(s.turnover_rate)).filter((v): v is number => v != null)
const volValues = stocks.map(s => num(s.vol_ratio_5d)).filter((v): v is number => v != null)
const totalAmount = stocks.reduce((sum, s) => sum + (num(s.amount) ?? 0), 0)
const upCount = pctValues.filter(v => v > 0).length
const downCount = pctValues.filter(v => v < 0).length
const flatCount = Math.max(0, stocks.length - upCount - downCount)
const strongCount = pctValues.filter(v => v >= 0.05).length
const weakCount = pctValues.filter(v => v <= -0.05).length
const leader = stocks.length ? [...stocks].sort((a, b) => b.leaderScore - a.leaderScore)[0] : null
const avgPct = avg(pctValues)
const medianPct = median(pctValues)
const upRate = pctValues.length ? upCount / pctValues.length : 0
const amountScore = clamp01(Math.log1p(totalAmount) / Math.log1p(80_000_000_000))
const strongScore = stocks.length ? clamp01(strongCount / Math.max(1, stocks.length * 0.18)) : 0
const leaderPart = clamp01((leader?.leaderScore ?? 0) / 100)
const avgPart = clamp01(((avgPct ?? 0) + 0.02) / 0.09)
const upPart = clamp01((upRate - 0.35) / 0.55)
const heatScore = (avgPart * 0.38 + upPart * 0.2 + strongScore * 0.16 + amountScore * 0.12 + leaderPart * 0.14) * 100
const riskScore = (clamp01((-(avgPct ?? 0) + 0.01) / 0.08) * 0.55 + clamp01(weakCount / Math.max(1, stocks.length * 0.18)) * 0.45) * 100
return {
key: group.key,
stocks,
count: stocks.length,
avgPct,
medianPct,
upCount,
downCount,
flatCount,
upRate,
strongCount,
weakCount,
totalAmount,
avgTurnover: avg(turnoverValues),
avgVolRatio: avg(volValues),
leader,
heatScore,
riskScore,
}
}
function statSort(mode: SortMode) {
return (a: IndustryStat, b: IndustryStat) => {
switch (mode) {
case 'avgPct': return (b.avgPct ?? -Infinity) - (a.avgPct ?? -Infinity)
case 'leader': return (b.leader?.leaderScore ?? -Infinity) - (a.leader?.leaderScore ?? -Infinity)
case 'amount': return b.totalAmount - a.totalAmount
case 'down': return (a.avgPct ?? Infinity) - (b.avgPct ?? Infinity)
case 'heat':
default: return b.heatScore - a.heatScore
}
}
}
// ===== 行业层级分组 =====
function industryLevelName(key: string, level: IndustryLevel) {
const parts = key.split('-').map(s => s.trim()).filter(Boolean)
return parts[level - 1] || parts[parts.length - 1] || key
}
function groupByIndustryLevel(groups: DimensionGroup[], level: IndustryLevel): DimensionGroup[] {
const map = new Map<string, DimensionGroup>()
for (const group of groups) {
const key = industryLevelName(group.key, level)
const existing = map.get(key)
if (existing) {
existing.stocks.push(...group.stocks)
existing.count = existing.stocks.length
} else {
map.set(key, {
key,
count: group.stocks.length,
stocks: [...group.stocks],
metrics: { ...group.metrics },
})
}
}
return [...map.values()].sort((a, b) => b.count - a.count)
}
// ===== 主页面 =====
export function IndustryAnalysis() {
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
const [showConfig, setShowConfig] = useState(false)
const [search, setSearch] = useState('')
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
const activeConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
enabled: !!activeConfigId,
})
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
staleTime: 60_000,
})
const marketMap = useMemo(() => buildMarketMap(marketQuery.data?.rows ?? []), [marketQuery.data?.rows])
const resolved = useMemo(
() => resolveDimension(rowsQuery.data, activeConfig, fieldConfig.dimensionField ? [fieldConfig.dimensionField, ...CANDIDATE_FIELDS] : CANDIDATE_FIELDS),
[rowsQuery.data, activeConfig, fieldConfig.dimensionField],
)
const industryLevel = fieldConfig.hierarchyLevel ?? 2
const groups = useMemo(() => groupByIndustryLevel(resolved.groups, industryLevel), [resolved.groups, industryLevel])
const stats = useMemo(() => {
return groups
.map(g => calcIndustryStat(g, marketMap))
.filter(s => s.count > 0)
}, [groups, marketMap])
const filteredStats = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q ? stats.filter(s => s.key.toLowerCase().includes(q)) : stats
return [...base].sort(statSort(sortMode))
}, [stats, search, sortMode])
const selected = filteredStats.find(s => s.key === selectedKey) ?? filteredStats[0] ?? null
const leading = useMemo(() => [...stats].sort(statSort('heat')).slice(0, 10), [stats])
const falling = useMemo(() => [...stats].sort(statSort('down')).slice(0, 10), [stats])
const activeIndustry = useMemo(() => [...stats].sort(statSort('amount'))[0] ?? null, [stats])
const industryBreadth = useMemo(() => {
const priced = stats.filter(s => s.avgPct != null)
return {
up: priced.filter(s => (s.avgPct ?? 0) > 0).length,
down: priced.filter(s => (s.avgPct ?? 0) < 0).length,
flat: priced.filter(s => s.avgPct === 0).length,
}
}, [stats])
const totalSymbols = useMemo(() => {
const set = new Set<string>()
stats.forEach(s => s.stocks.forEach(st => { if (st.symbol) set.add(st.symbol) }))
return set.size
}, [stats])
// 热力图用的 quoteMap(兼容 DimensionHeatmap 组件接口)
const heatmapQuoteMap = useMemo(() => {
const map = new Map<string, { symbol: string; pct?: number; change_pct?: number; name?: string; [k: string]: unknown }>()
for (const [k, v] of marketMap) {
map.set(k, {
...v,
change_pct: v.change_pct ?? undefined,
name: v.name ?? undefined,
})
}
return map
}, [marketMap])
const handleSaveConfig = (c: AnalysisFieldConfig) => {
setFieldConfig(c)
saveConfig(c)
setSelectedKey(null)
}
if (configsQuery.isLoading) {
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div>
}
if (!activeConfig) {
return (
<div className="flex h-full flex-col">
<PageHeader title="行业分析" />
<EmptyState icon={Database} title="暂无行业数据" hint={'请先在"数据"页面创建包含行业/板块字段的扩展数据源'} />
</div>
)
}
const industryLevelLabel = `${industryLevel}级行业`
return (
<>
<PageHeader
title="行业分析"
subtitle={`${industryLevelLabel} · ${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个行业 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
className="p-1.5 text-muted hover:bg-surface disabled:opacity-50"
title="刷新"
>
<RefreshCw className={cn('h-4 w-4', (rowsQuery.isFetching || marketQuery.isFetching) && 'animate-spin')} />
</button>
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
</div>
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_12%_0%,rgba(245,158,11,0.12),transparent_28%),radial-gradient(circle_at_85%_8%,rgba(244,63,94,0.08),transparent_28%)] px-6 py-5">
<div className="mx-auto max-w-[1440px] space-y-5">
<HeroPanel leading={leading[0]} falling={falling[0]} activeIndustry={activeIndustry} industryBreadth={industryBreadth} />
<MarketPulse
leading={leading}
falling={falling}
selectedKey={selected?.key ?? null}
onSelect={setSelectedKey}
onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }}
/>
{/* 热力图 */}
{groups.length > 0 && (
<DimensionHeatmap
groups={groups}
quoteMap={heatmapQuoteMap}
selectedKey={selectedKey}
onSelect={k => setSelectedKey(k)}
colorScheme="amber"
/>
)}
{stats.length > 0 ? (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[18rem_1fr]">
<IndustryRail
stats={filteredStats.slice(0, MAX_RENDERED_INDUSTRIES)}
selectedKey={selected?.key ?? null}
search={search}
sortMode={sortMode}
onSearch={v => { setSearch(v); setSelectedKey(null) }}
onSort={setSortMode}
onSelect={setSelectedKey}
/>
<IndustryFocus stat={selected} onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div>
) : (
<EmptyState icon={Layers3} title="未匹配到行业数据" hint={resolved.hint || '请检查扩展数据是否包含行业/板块相关字段'} />
)}
</div>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
</AnimatePresence>
{previewSymbol && (
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => { setPreviewSymbol(null); setPreviewName('') }}
/>
)}
</>
)
}
// ===== HeroPanel =====
function HeroPanel({
leading,
falling,
activeIndustry,
industryBreadth,
}: {
leading?: IndustryStat
falling?: IndustryStat
activeIndustry?: IndustryStat | null
industryBreadth: { up: number; down: number; flat: number }
}) {
return (
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
<HeroMetric icon={TrendingUp} label="最强行业" value={leading?.key ?? '—'} hint={leading?.avgPct != null ? <span className={priceColorClass(leading.avgPct)}>{fmtPct(leading.avgPct)}</span> : '等待行情'} tone="up" />
<HeroMetric icon={TrendingDown} label="最大风险" value={falling?.key ?? '—'} hint={falling?.avgPct != null ? <span className={priceColorClass(falling.avgPct)}>{fmtPct(falling.avgPct)}</span> : '等待行情'} tone="down" />
<HeroMetric
icon={Activity}
label="涨跌行业"
value={<><span className="text-bull">{industryBreadth.up}</span><span className="mx-1 text-muted">/</span><span className="text-bear">{industryBreadth.down}</span></>}
hint={<><span className="text-bull"></span><span className="mx-1 text-muted">/</span><span className="text-bear"></span>{industryBreadth.flat ? <span className="text-muted"> · {industryBreadth.flat}</span> : null}</>}
tone="blue"
/>
<HeroMetric icon={Activity} label="资金活跃" value={activeIndustry?.key ?? '—'} hint={activeIndustry ? fmtBigNum(activeIndustry.totalAmount) : '等待行情'} tone="blue" />
<HeroMetric icon={Crown} label="龙头算法" value="6 因子" hint="强势 + 承接 + 容量" tone="gold" />
</div>
)
}
function HeroMetric({ icon: Icon, label, value, hint, tone }: {
icon: typeof TrendingUp
label: string
value: ReactNode
hint: ReactNode
tone: 'up' | 'down' | 'gold' | 'blue'
}) {
const toneClass = {
up: 'text-bull bg-bull/10',
down: 'text-bear bg-bear/10',
gold: 'text-amber-300 bg-amber-400/10',
blue: 'text-blue-300 bg-blue-400/10',
}[tone]
const valueClass = {
up: 'text-bull',
down: 'text-bear',
gold: 'text-amber-300',
blue: 'text-foreground',
}[tone]
return (
<div className="rounded-xl border border-border bg-surface px-3 py-2">
<div className="flex items-center justify-between text-[11px] text-muted">
<span>{label}</span>
<span className={cn('rounded-md p-1', toneClass)}><Icon className="h-3.5 w-3.5" /></span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', valueClass)}>{value}</div>
<div className="mt-0.5 truncate text-[11px] text-muted">{hint}</div>
</div>
)
}
// ===== MarketPulse =====
function MarketPulse({
leading,
falling,
selectedKey,
onSelect,
onStockClick,
}: {
leading: IndustryStat[]
falling: IndustryStat[]
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string) => void
}) {
return (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
</div>
)
}
function PulseList({
title,
items,
mode,
selectedKey,
onSelect,
onStockClick,
}: {
title: string
items: IndustryStat[]
mode: 'up' | 'down'
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string) => void
}) {
const toneText = mode === 'up' ? 'text-bull' : 'text-bear'
const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20'
const toneBg = mode === 'up' ? 'bg-bull/10' : 'bg-bear/10'
const toneHover = mode === 'up' ? 'hover:border-bull/35' : 'hover:border-bear/35'
return (
<div className={cn('rounded-xl border bg-base/35 p-2', toneBorder)}>
<div className="mb-1.5 flex items-center justify-between px-1">
<div className={cn('flex items-center gap-1.5 text-xs font-medium', toneText)}>
{mode === 'up' ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
{title}
</div>
<span className="rounded-full bg-elevated/60 px-2 py-0.5 text-[10px] text-muted">Top 10</span>
</div>
<div className="space-y-1">
{items.map((item, idx) => {
const active = selectedKey === item.key
const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3)
const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0
const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0
const flatPct = Math.max(0, 100 - upPct - downPct)
return (
<button
key={item.key}
onClick={() => onSelect(item.key)}
className={cn(
'block w-full rounded-lg border border-transparent bg-surface/45 px-2 py-1.5 text-left transition-colors',
active ? cn('bg-blue-400/[0.08]', toneBorder) : cn('hover:bg-elevated/35', toneHover),
)}
>
<div className="grid gap-2 md:grid-cols-[minmax(0,0.9fr)_minmax(16rem,1.1fr)] md:items-center">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md font-mono text-[10px]', idx < 3 ? cn(toneBg, toneText) : 'bg-elevated/70 text-muted')}>{idx + 1}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{item.key}</span>
<span className="shrink-0 text-[10px] text-muted">
<span className="text-bull">{item.upCount}</span>
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{item.downCount}</span>
</span>
<span className={cn('ml-auto shrink-0 font-mono text-[10px] tabular-nums', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
</div>
</div>
<div className="ml-7 mt-1">
<div className="flex h-1 overflow-hidden rounded-full bg-elevated">
<div className="h-full bg-bull/70" style={{ width: `${upPct}%` }} />
{flatPct > 0 && <div className="h-full bg-muted/25" style={{ width: `${flatPct}%` }} />}
<div className="h-full bg-bear/70" style={{ width: `${downPct}%` }} />
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-3 gap-1">
{Array.from({ length: 3 }).map((_, i) => {
const stock = leaders[i]
return stock ? (
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary')}>
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
</span>
<span className={cn('shrink-0 font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</span>
) : <span key={i} className="rounded-md bg-elevated/30 px-1.5 py-0.5 text-[10px] text-muted/40"></span>
})}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
// ===== IndustryRail(左侧列表面板) =====
function IndustryRail({
stats,
selectedKey,
search,
sortMode,
onSearch,
onSort,
onSelect,
}: {
stats: IndustryStat[]
selectedKey: string | null
search: string
sortMode: SortMode
onSearch: (v: string) => void
onSort: (v: SortMode) => void
onSelect: (v: string) => void
}) {
return (
<section className="rounded-2xl border border-border bg-surface p-2.5">
<div className="px-1 pb-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground"></h3>
<span className="text-[10px] text-muted">Top {stats.length}</span>
</div>
<div className="mt-2 relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder="搜索行业" className="h-8 w-full rounded-lg border border-border bg-base pl-8 pr-3 text-xs text-foreground outline-none focus:border-accent/50" />
</div>
<div className="mt-2 grid grid-cols-5 overflow-hidden rounded-lg border border-border text-[10px]">
{([
['heat', '强度'], ['avgPct', '涨幅'], ['leader', '龙头'], ['amount', '成交'], ['down', '跌幅'],
] as [SortMode, string][]).map(([key, label]) => (
<button key={key} onClick={() => onSort(key)} className={cn('py-1.5 transition-colors', sortMode === key ? 'bg-amber-500/15 text-amber-400' : 'bg-base text-muted hover:text-foreground')}>{label}</button>
))}
</div>
</div>
<div className="max-h-[620px] overflow-auto rounded-lg border border-border/50">
{stats.map(item => {
const active = selectedKey === item.key
return (
<button key={item.key} onClick={() => onSelect(item.key)} className={cn('w-full border-b border-border/50 px-2.5 py-2 text-left transition-colors last:border-b-0', active ? 'bg-amber-500/[0.08]' : 'hover:bg-elevated/40')}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{item.key}</span>
<span className={cn('font-mono text-xs', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted">
<span>{item.count}</span>
<span className="text-bull">{item.upCount}</span>
<span className="text-bear">{item.downCount}</span>
<span className="ml-auto"> {item.heatScore.toFixed(0)}</span>
</div>
</button>
)
})}
</div>
</section>
)
}
// ===== IndustryFocus(右侧聚焦面板) =====
function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onStockClick: (symbol: string, name?: string) => void }) {
if (!stat) return null
const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS)
const topLeaders = stocks.slice(0, 3)
return (
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<h3 className="truncate text-xl font-semibold text-foreground">{stat.key}</h3>
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-400"> {stat.heatScore.toFixed(0)}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<span>{stat.count} </span>
<span className={priceColorClass(stat.avgPct)}> {stat.avgPct != null ? fmtPct(stat.avgPct) : '—'}</span>
<span> {(stat.upRate * 100).toFixed(0)}%</span>
<span> {fmtBigNum(stat.totalAmount)}</span>
{stat.avgTurnover != null && <span> {stat.avgTurnover.toFixed(2)}%</span>}
</div>
</div>
<div className="grid grid-cols-5 gap-2 lg:w-[520px]">
<MiniStat label="均涨" value={stat.avgPct != null ? fmtPct(stat.avgPct) : '—'} cls={priceColorClass(stat.avgPct)} />
<MiniStat label="中位" value={stat.medianPct != null ? fmtPct(stat.medianPct) : '—'} cls={priceColorClass(stat.medianPct)} />
<MiniStat label="强势" value={`${stat.strongCount}`} cls="text-bull" />
<MiniStat label="弱势" value={`${stat.weakCount}`} cls="text-bear" />
<MiniStat label="量比" value={stat.avgVolRatio != null ? stat.avgVolRatio.toFixed(2) : '—'} cls="text-foreground" />
</div>
</div>
</div>
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
<LeaderStage stocks={topLeaders} onStockClick={onStockClick} />
<ScoreExplain stock={topLeaders[0]} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/60 text-[11px] text-muted">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{stocks.map((s, idx) => (
<tr key={`${s.symbol}-${idx}`} className="hover:bg-elevated/30 cursor-pointer" onClick={() => onStockClick(s.symbol, s.name || undefined)}>
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
<td className="px-4 py-2">
<div className="font-medium text-foreground">{s.name || '—'}</div>
<div className="font-mono text-[10px] text-muted">{s.symbol}</div>
</td>
<td className={cn('px-4 py-2 font-mono tabular-nums', priceColorClass(s.change_pct))}>{s.change_pct != null ? fmtPct(s.change_pct) : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.turnover_rate != null ? `${s.turnover_rate.toFixed(2)}%` : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.amount)}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.float_market_cap ?? s.market_cap)}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.vol_ratio_5d != null ? s.vol_ratio_5d.toFixed(2) : '—'}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<span className="w-9 font-mono text-amber-300">{s.leaderScore.toFixed(0)}</span>
<div className="h-1.5 w-16 rounded-full bg-elevated"><div className="h-full rounded-full bg-amber-300" style={{ width: `${Math.max(4, s.leaderScore)}%` }} /></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{stat.stocks.length > MAX_RENDERED_STOCKS && <div className="shrink-0 border-t border-border px-4 py-2 text-center text-[11px] text-muted"> {MAX_RENDERED_STOCKS} {stat.stocks.length} </div>}
</section>
)
}
function MiniStat({ label, value, cls }: { label: string; value: string; cls: string }) {
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
}
function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) {
if (!stocks.length) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted"></div>
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-amber-300">
<Crown className="h-3.5 w-3.5" />
</div>
<div className="grid gap-2 md:grid-cols-3">
{stocks.map((stock, idx) => (
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}>
<div className="flex items-center justify-between gap-2">
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
</div>
<div className="mt-2 truncate text-sm font-medium text-foreground">{stock.name || stock.symbol}</div>
<div className="mt-0.5 flex items-center justify-between text-[11px]">
<span className="font-mono text-muted">{stock.symbol}</span>
<span className={cn('font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</div>
</div>
))}
</div>
</div>
)
}
function ScoreExplain({ stock }: { stock?: EnrichedStock }) {
if (!stock) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted"></div>
const parts = stock.leaderParts
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-foreground"></span>
<span className="text-[11px] text-muted"> / / / / / </span>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
<Part label="动能" value={parts.momentum} cls="bg-rose-400" />
<Part label="换手" value={parts.turnover} cls="bg-orange-400" />
<Part label="成交" value={parts.amount} cls="bg-blue-400" />
<Part label="市值" value={parts.cap} cls="bg-cyan-400" />
<Part label="量比" value={parts.volume} cls="bg-purple-400" />
<Part label="连板" value={parts.boards} cls="bg-amber-300" />
</div>
</div>
)
}
function Part({ label, value, cls }: { label: string; value: number; cls: string }) {
return <div className="rounded-lg bg-base/35 px-2 py-1.5"><div className="mb-1 flex justify-between text-[10px] text-muted"><span>{label}</span><span>{Math.round(value * 100)}</span></div><div className="h-1 rounded-full bg-elevated"><div className={cn('h-full rounded-full', cls)} style={{ width: `${Math.max(3, value * 100)}%` }} /></div></div>
}
File diff suppressed because it is too large Load Diff
+698
View File
@@ -0,0 +1,698 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
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 { QK } from '@/lib/queryKeys'
import { fmtPrice, fmtPct } from '@/lib/format'
import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals'
import { boardTag } from '@/components/stock-table/primitives'
import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge'
import { RuleEditor } from '@/components/monitor/RuleEditor'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
const TYPE_LABEL: Record<string, string> = {
signal: '个股信号', price: '价格/涨跌', market: '市场异动', strategy: '策略监控',
}
/** 严重级别 → 左侧色条 + 图标 */
const SEVERITY_CONFIG: Record<string, { bar: string; icon: any; iconCls: string }> = {
info: { bar: 'bg-accent/40', icon: Bell, iconCls: 'text-accent' },
warn: { bar: 'bg-warning', icon: TrendingUp, iconCls: 'text-warning' },
critical: { bar: 'bg-danger', icon: Flame, iconCls: 'text-danger' },
}
const SOURCE_BADGE_STYLE: Record<string, string> = {
strategy: 'bg-amber-400/10 text-amber-400 border-amber-400/20',
signal: 'bg-accent/10 text-accent border-accent/20',
price: 'bg-emerald-400/10 text-emerald-400 border-emerald-400/20',
market: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
}
/**
* 渲染策略类消息 — 策略名黄色、新入选绿、移出红、其余白色。
*/
function renderMessage(source: string, message: string) {
if (source !== 'strategy') {
return <span className="text-secondary">{message}</span>
}
const m = message.match(/^(策略「)([^」]+)(」)(新入选|移出)( .*)$/)
if (!m) return <span className="text-foreground">{message}</span>
const [, pre, strategyName, mid, direction, post] = m
return (
<>
<span className="text-foreground/80">{pre}</span>
<span className="text-amber-400 font-medium">{strategyName}</span>
<span className="text-foreground/80">{mid}</span>
<span className={direction === '新入选' ? 'text-emerald-400 font-medium' : 'text-danger font-medium'}>{direction}</span>
<span className="text-foreground/80">{post}</span>
</>
)
}
export function Monitor() {
const qc = useQueryClient()
const [editorOpen, setEditorOpen] = useState(false)
const [editingRule, setEditingRule] = useState<MonitorRule | null>(null)
// 触发记录: 过滤 + 统计 (提升到主组件, 供 header 行使用)
const [filter, setFilter] = useState<'all' | 'strategy' | 'signal' | 'price' | 'market'>('all')
const [confirmClear, setConfirmClear] = useState(false)
const [confirmClearRules, setConfirmClearRules] = useState(false)
const alertsQuery = useQuery({
queryKey: QK.alerts(filter === 'all' ? undefined : filter),
queryFn: () => api.alertsList({ days: 7, limit: 500, source: filter === 'all' ? undefined : filter }),
refetchInterval: 10000,
refetchIntervalInBackground: true,
})
const total = alertsQuery.data?.total ?? 0
// 规则个数
const rulesQuery = useQuery({ queryKey: QK.monitorRules, queryFn: api.monitorRulesList })
const rulesCount = rulesQuery.data?.rules.length ?? 0
// 清除全部规则 (逐条删除)
const clearRulesMut = useMutation({
mutationFn: async () => {
const rules = rulesQuery.data?.rules ?? []
await Promise.all(rules.map(r => api.monitorRuleDelete(r.id)))
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.monitorRules })
setConfirmClearRules(false)
},
})
// 进入监控页: 清零未读徽标 + 记录"进入时刻", 之后新增的记录会闪烁
// 离开监控页: 停止同步, 之后新增才计入未读
const enterTsRef = useRef<number>(Date.now())
useEffect(() => {
enterTsRef.current = Date.now()
markSeen()
return () => leaveMonitorPage()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="flex flex-col h-full">
<PageHeader title="监控中心" subtitle="实时信号与规则管理" />
<div className="flex-1 min-h-0 px-5 py-4">
<div className="mx-auto flex h-full max-w-7xl flex-col gap-4 lg:flex-row">
{/* 左栏: 触发记录 */}
<section className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-surface/40 shadow-lg shadow-black/5">
<div className="flex items-center gap-3 border-b border-border/60 bg-surface/60 px-4 py-2.5">
<SectionHeader icon={BellRing} title="触发记录" />
{/* 过滤标签 */}
<div className="flex flex-wrap items-center gap-0.5">
{(['all', 'strategy', 'signal', 'price', 'market'] as const).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-all cursor-pointer',
filter === f ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-elevated/60 hover:text-secondary',
)}
>
{f === 'all' ? '全部' : TYPE_LABEL[f]}
</button>
))}
</div>
{/* 数量 + 清空 */}
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="rounded-md bg-elevated/50 px-1.5 py-0.5 text-[10px] font-medium text-muted">{total}</span>
{total > 0 && (
<button
onClick={() => setConfirmClear(true)}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-muted transition-colors hover:bg-danger/10 hover:text-danger cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3.5">
<AlertsList alertsQuery={alertsQuery} confirmClear={confirmClear} setConfirmClear={setConfirmClear} total={total} enterTs={enterTsRef.current} />
</div>
</section>
{/* 右栏: 监控规则 */}
<section className="flex min-h-0 w-full flex-col overflow-hidden rounded-xl border border-border bg-surface/40 shadow-lg shadow-black/5 lg:w-[400px] lg:shrink-0">
<div className="flex items-center gap-3 border-b border-border/60 bg-surface/60 px-4 py-2.5">
<SectionHeader icon={ListChecks} title="监控规则" />
<span className="rounded-md bg-elevated/50 px-1.5 py-0.5 text-[10px] font-medium text-muted">{rulesCount}</span>
<div className="ml-auto flex items-center gap-1">
<button
onClick={() => { setEditingRule(null); setEditorOpen(true) }}
title="新建规则"
className="inline-flex h-6 w-6 items-center justify-center rounded-lg border border-border/60 bg-surface text-muted transition-all hover:border-accent/40 hover:text-accent hover:shadow-sm cursor-pointer"
>
<Plus className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setConfirmClearRules(true)}
disabled={rulesCount === 0}
title="清除全部规则"
className="inline-flex h-6 w-6 items-center justify-center rounded-lg border border-border/60 bg-surface text-muted transition-all hover:border-danger/40 hover:text-danger disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3.5">
<RulesList
rulesQuery={rulesQuery}
onEdit={(r) => { setEditingRule(r); setEditorOpen(true) }}
/>
</div>
</section>
</div>
</div>
<RuleEditorDialog
open={editorOpen}
rule={editingRule}
onClose={() => { setEditorOpen(false); setEditingRule(null) }}
/>
<ConfirmDialog
open={confirmClearRules}
title="清除全部监控规则?"
message={`将删除全部 ${rulesCount} 条规则,此操作不可撤销。`}
confirmText="清除"
danger
onCancel={() => setConfirmClearRules(false)}
onConfirm={() => clearRulesMut.mutate()}
pending={clearRulesMut.isPending}
/>
</div>
)
}
function SectionHeader({ icon: Icon, title }: { icon: any; title: string }) {
return (
<div className="flex items-center gap-1.5 shrink-0">
<Icon className="h-4 w-4 text-accent" />
<h2 className="text-sm font-semibold text-foreground whitespace-nowrap">{title}</h2>
</div>
)
}
// ── 触发记录列表 ──────────────────────────────────────
function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs }: {
alertsQuery: ReturnType<typeof useQuery>
confirmClear: boolean
setConfirmClear: (v: boolean) => void
total: number
enterTs: number
}) {
const qc = useQueryClient()
const [confirmTs, setConfirmTs] = useState<number | null>(null)
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(null)
const clearMut = useMutation({
mutationFn: api.alertsClear,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['alerts'] }); setConfirmClear(false); resetBadge() },
})
const delMut = useMutation({
mutationFn: (ts: number) => api.alertDelete(ts),
onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
})
// 点击删除: 第一次进入确认态, 第二次真删, 3 秒后自动复位
const handleClickDelete = (ts: number) => {
if (confirmTs === ts) {
// 第二次点击 → 真删
if (resetTimer.current) clearTimeout(resetTimer.current)
setConfirmTs(null)
delMut.mutate(ts)
} else {
// 第一次点击 → 进入确认态, 3 秒后自动复位
setConfirmTs(ts)
if (resetTimer.current) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => setConfirmTs(null), 3000)
}
}
const events = (alertsQuery.data as any)?.alerts ?? []
return (
<div className="space-y-3">
{events.length === 0 ? (
<EmptyState
icon={Bell}
title="暂无触发记录"
hint="监控规则命中后,触发记录会出现在这里。可在右侧配置规则,或在个股详情页加入监控。"
/>
) : (
<div className="space-y-2">
{events
.filter((ev: any) => !(ev.source === 'strategy' && !ev.symbol))
.map((ev: any, i: number) => {
const sev = SEVERITY_CONFIG[ev.severity ?? 'info'] ?? SEVERITY_CONFIG.info
const SevIcon = sev.icon
const isNew = ev.ts > enterTs
return (
<motion.div
key={`${ev.ts}-${i}`}
initial={isNew ? { opacity: 0, y: -8, scale: 0.98 } : { opacity: 0, y: 4 }}
animate={isNew ? {
opacity: [0, 1, 1, 0.85, 1],
scale: [0.98, 1, 1, 1.01, 1],
y: [-8, 0, 0, 0, 0],
} : { opacity: 1, y: 0 }}
transition={isNew ? { duration: 1.2, times: [0, 0.2, 0.5, 0.75, 1] } : { duration: 0.2, delay: Math.min(i * 0.02, 0.2) }}
className={cn(
'group relative flex items-start gap-3 overflow-hidden rounded-lg border bg-surface pl-3.5 pr-3 py-2.5 shadow-sm transition-all duration-200 hover:border-border hover:shadow-md hover:shadow-black/10 hover:-translate-y-px',
isNew ? 'border-accent/60 ring-1 ring-accent/30' : 'border-border/50',
)}
>
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev.bar)} />
<div className={cn('mt-px shrink-0', sev.iconCls)}>
<SevIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
{ev.source === 'strategy' ? (() => {
const sm = ev.message?.match(/策略「([^」]+)」/)
const sname = sm ? sm[1] : ''
const isNew = ev.type === 'new_entry'
const _pct = ev.change_pct ?? 0
return (
<>
<div className="flex items-center gap-2 flex-wrap">
{ev.symbol && (() => {
const board = boardTag(ev.symbol)
return (
<button
onClick={() => setPreviewEv(ev)}
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
title="点击查看日K"
>
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{ev.symbol}</span>
{board && (
<span className={`inline-flex items-center justify-center h-3.5 w-3.5 rounded text-[8px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
)}
{ev.name && <span className="text-xs text-secondary truncate max-w-[8rem] hover:text-foreground">{ev.name}</span>}
</button>
)
})()}
{ev.price != null && (
<span className={cn('inline-flex items-center gap-0.5 text-[11px] font-mono', _pct >= 0 ? 'text-danger' : 'text-bear')}>
{_pct >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
{fmtPrice(ev.price)}
</span>
)}
{ev.change_pct != null && (
<span className={cn('text-[11px] font-mono font-medium',
_pct >= 0 ? 'text-danger' : 'text-bear')}>
{fmtPct(_pct)}
</span>
)}
<span className={cn('rounded border px-1.5 py-0.5 text-[9px] font-medium', SOURCE_BADGE_STYLE.strategy)}>
{sname}
</span>
</div>
<div className="mt-1 flex items-center gap-1.5">
<span className={cn('text-[11px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
{isNew ? '进入' : '移出'}
</span>
<span className="text-[11px] text-foreground/80"></span>
<span className="text-[11px] font-medium text-amber-400">{sname}</span>
</div>
</>
)
})() : (
<>
<div className="flex items-center gap-2 flex-wrap">
{ev.symbol && (() => {
const board = boardTag(ev.symbol)
return (
<button
onClick={() => setPreviewEv(ev)}
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
title="点击查看日K"
>
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{ev.symbol}</span>
{board && (
<span className={`inline-flex items-center justify-center h-3.5 w-3.5 rounded text-[8px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
)}
{ev.name && <span className="text-xs text-secondary truncate max-w-[8rem] hover:text-foreground">{ev.name}</span>}
</button>
)
})()}
{ev.price != null && (
<span className={cn('inline-flex items-center gap-0.5 text-[11px] font-mono', (ev.change_pct ?? 0) >= 0 ? 'text-danger' : 'text-bear')}>
{(ev.change_pct ?? 0) >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
{fmtPrice(ev.price)}
</span>
)}
{ev.change_pct != null && (
<span className={cn('text-[11px] font-mono font-medium',
ev.change_pct >= 0 ? 'text-danger' : 'text-bear')}>
{fmtPct(ev.change_pct)}
</span>
)}
<span className={cn('rounded border px-1.5 py-0.5 text-[9px] font-medium', SOURCE_BADGE_STYLE[ev.source] ?? 'bg-elevated text-muted border-border')}>
{(() => {
// 优先用规则名 (如 "策略监控 · 空中加油" → "空中加油"); 退回到 type 标签
const rn = ev.rule_name ?? ''
const dotIdx = rn.indexOf(' · ')
return dotIdx >= 0 ? rn.slice(dotIdx + 3) : (rn || (TYPE_LABEL[ev.source] ?? ev.source))
})()}
</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) => (
<span key={j} className="rounded bg-accent/8 px-1.5 py-0.5 text-[9px] text-accent/70">{cnSignal(s)}</span>
))}
</div>
)}
</>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="text-[10px] text-muted/60 font-mono">
{new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
{confirmTs === ev.ts ? (
// 确认态: 红色实心按钮 (原删除图标位置), 再点确认删除
<button
onClick={() => handleClickDelete(ev.ts)}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[10px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
) : (
<button
onClick={() => handleClickDelete(ev.ts)}
disabled={delMut.isPending}
title="删除"
className="rounded p-1 text-muted/0 transition-colors group-hover:text-muted/40 hover:!text-danger hover:bg-danger/10 cursor-pointer"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
</motion.div>
)
})}
</div>
)}
<ConfirmDialog
open={confirmClear}
title="清空全部触发记录?"
message={`将删除全部 ${total} 条记录,此操作不可撤销。`}
confirmText="清空"
danger
onCancel={() => setConfirmClear(false)}
onConfirm={() => clearMut.mutate()}
pending={clearMut.isPending}
/>
<StockPreviewDialog
symbol={previewEv?.symbol ?? null}
name={previewEv?.name ?? undefined}
triggerInfo={previewEv ? {
price: previewEv.price ?? null,
changePct: previewEv.change_pct ?? null,
ts: previewEv.ts,
signals: previewEv.signals,
message: previewEv.message,
} : null}
onClose={() => setPreviewEv(null)}
/>
</div>
)
}
// ── 监控规则列表 ──────────────────────────────────────
function RulesList({ rulesQuery, onEdit }: {
rulesQuery: ReturnType<typeof useQuery>
onEdit: (rule: MonitorRule) => void
}) {
const qc = useQueryClient()
const [confirmId, setConfirmId] = useState<string | null>(null)
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const rules: MonitorRule[] = (rulesQuery.data as any)?.rules ?? []
// 收集所有规则的股票代码, 批量查名称
const allSymbols = useMemo(() => {
const set = new Set<string>()
for (const r of rules) {
if (r.scope === 'symbols') r.symbols.forEach(s => set.add(s))
}
return Array.from(set)
}, [rules])
const namesQuery = useQuery({
queryKey: ['instrument-names', allSymbols.join(',')],
queryFn: () => api.instrumentNames(allSymbols),
enabled: allSymbols.length > 0,
staleTime: 300000,
})
const symbolNames = namesQuery.data?.names ?? {}
const del = useMutation({
mutationFn: api.monitorRuleDelete,
onSuccess: () => qc.invalidateQueries({ queryKey: QK.monitorRules }),
})
const toggleEnabled = (rule: MonitorRule) => {
api.monitorRuleSave({ ...rule, enabled: !rule.enabled }).then(() =>
qc.invalidateQueries({ queryKey: QK.monitorRules }),
)
}
// 点击删除: 第一次进入确认态, 第二次真删, 3 秒后自动复位
const handleClickDelete = (id: string) => {
if (confirmId === id) {
if (resetTimer.current) clearTimeout(resetTimer.current)
setConfirmId(null)
del.mutate(id)
} else {
setConfirmId(id)
if (resetTimer.current) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => setConfirmId(null), 3000)
}
}
return (
<div className="space-y-2.5">
{rules.length === 0 ? (
<EmptyState
icon={RadioTower}
title="暂无监控规则"
hint="点击标题栏「+」新建规则,或在个股详情页点「加监控」快速添加。"
/>
) : (
rules.map(r => {
// 名称截取: "策略监控 · MACD金叉" → "MACD金叉", "个股信号监控 · 300750.SZ" → "个股信号监控"
const dotIdx = r.name.indexOf(' · ')
const displayName = dotIdx >= 0 ? r.name.slice(dotIdx + 3) : r.name
return (
<motion.div
key={r.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className={cn(
'group relative overflow-hidden rounded-lg border pl-3.5 pr-2.5 py-2 shadow-sm transition-all duration-200 hover:shadow-md hover:shadow-black/10',
r.enabled
? 'border-border/50 bg-surface hover:border-accent/30'
: 'border-border/30 bg-surface/40 opacity-70 hover:opacity-100',
)}
>
{/* 左侧状态条 */}
<div className={cn('absolute left-0 top-0 h-full w-0.5', r.enabled ? 'bg-accent/50' : 'bg-border')} />
{/* 第一行: 分类标签 + 名称 + 操作按钮 */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold', SOURCE_BADGE_STYLE[r.type] ?? 'bg-elevated text-muted')}>
{TYPE_LABEL[r.type]}
</span>
{/* 个股类型: 直接显示可点击的代码+名称; 其他类型显示规则名 */}
{r.scope === 'symbols' && r.symbols.length > 0 ? (
<button
onClick={() => setPreviewSymbol(r.symbols[0])}
className="inline-flex items-center gap-1 min-w-0 hover:bg-elevated/50 rounded px-0.5 transition-colors cursor-pointer"
title={`查看 ${r.symbols[0]} 日K`}
>
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{r.symbols[0]}</span>
{symbolNames[r.symbols[0]] && <span className="text-xs text-secondary truncate">{symbolNames[r.symbols[0]]}</span>}
</button>
) : (
<h3 className={cn('text-xs font-medium truncate', r.enabled ? 'text-foreground' : 'text-muted')}>{displayName}</h3>
)}
{!r.enabled && <span className="shrink-0 text-[9px] text-secondary">· </span>}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => toggleEnabled(r)}
title={r.enabled ? '停用' : '启用'}
className={cn(
'p-1 rounded-md transition-all cursor-pointer',
r.enabled ? 'text-accent hover:bg-accent/10' : 'text-muted hover:bg-elevated hover:text-accent',
)}
>
<Zap className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onEdit(r)}
className="p-1 rounded-md text-secondary transition-all hover:bg-accent/10 hover:text-accent cursor-pointer"
title="编辑"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
{confirmId === r.id ? (
<button
onClick={() => handleClickDelete(r.id)}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[9px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
) : (
<button
onClick={() => handleClickDelete(r.id)}
disabled={del.isPending}
className="p-1 rounded-md text-secondary transition-all hover:bg-danger/10 hover:text-danger cursor-pointer"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* 第二行: 策略类型显示选股池变更监控 */}
{r.type === 'strategy' && r.strategy_id ? (
<div className="mt-0.5 flex items-center gap-2 pl-0.5">
<span className="text-[9px] text-secondary"></span>
</div>
) : r.conditions.length > 0 && (
<div className="mt-0.5 flex items-center gap-1 pl-0.5">
<span className="text-[9px] text-secondary shrink-0"></span>
<span className="min-w-0 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[9px]">
{r.conditions.slice(0, 3).map((c, i) => (
<span key={i} className="inline-flex items-center gap-0.5">
{i > 0 && <span className="text-secondary">{r.logic === 'and' ? '且' : '或'}</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>
))}
{r.conditions.length > 3 && <span className="text-secondary">+{r.conditions.length - 3}</span>}
</span>
</div>
)}
</motion.div>
)
})
)}
<StockPreviewDialog
symbol={previewSymbol}
name={previewSymbol ? symbolNames[previewSymbol] : undefined}
onClose={() => setPreviewSymbol(null)}
/>
</div>
)
}
// ── 规则编辑对话框 ────────────────────────────────────
function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: MonitorRule | null; onClose: () => void }) {
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/40 backdrop-blur-sm p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.15 }}
className="mt-12 w-full max-w-2xl"
onClick={e => e.stopPropagation()}
>
<RuleEditor
rule={rule}
onClose={onClose}
onSaved={onClose}
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
// ── 确认对话框 ────────────────────────────────────────
function ConfirmDialog({ open, title, message, confirmText, danger, pending, onCancel, onConfirm }: {
open: boolean
title: string
message: string
confirmText?: string
danger?: boolean
pending?: boolean
onCancel: () => void
onConfirm: () => void
}) {
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={onCancel}
>
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.15 }}
className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<p className="mt-1.5 text-xs text-muted">{message}</p>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onCancel} className="px-3 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer"></button>
<button
onClick={onConfirm}
disabled={pending}
className={cn(
'px-3 py-1.5 rounded-btn text-xs font-medium disabled:opacity-50 cursor-pointer',
danger ? 'bg-danger text-base' : 'bg-accent text-base',
)}
>
{confirmText ?? '确定'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+545
View File
@@ -0,0 +1,545 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import {
Eye,
EyeOff,
Loader2,
Save,
Check,
CheckCircle2,
AlertCircle,
ArrowRight,
ArrowLeft,
ExternalLink,
Sparkles,
LineChart,
ScanSearch,
Flame,
Zap,
Radar,
ShieldCheck,
BellRing,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { QK } from '@/lib/queryKeys'
import { CAP_LABELS } from '@/lib/capability-labels'
import { Logo } from '@/components/Logo'
// ===== 引导页:4 步向导 =====
// 0. 欢迎 1. 输入 Key(可跳过) 2. 能力探测结果 3. 完成 → 写标记 → 进面板
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' },
]
export function Onboarding() {
const navigate = useNavigate()
const qc = useQueryClient()
const [step, setStep] = useState(0)
// 完成向导 —— 写后端标记,使守卫放行
const complete = useMutation({
mutationFn: api.completeOnboarding,
onSuccess: (data) => {
// 用接口返回值同步更新缓存,确保跳转时守卫立即看到 onboarding_completed: true
// (避免 invalidate 后台重取未返回时, 守卫用旧缓存 false 误重定向回引导页)
qc.setQueryData(QK.settings, (old: any) =>
old ? { ...old, onboarding_completed: data.onboarding_completed } : old,
)
qc.invalidateQueries({ queryKey: QK.settings })
navigate('/', { replace: true })
},
onError: () => {
// 标记失败不应阻塞用户进入面板,仍放行
navigate('/', { replace: true })
},
})
const finish = () => complete.mutate()
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>
{/* 顶栏:logo + 进度指示 */}
<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>
{/* 步骤进度条 —— 胶囊式 */}
<div className="flex items-center gap-1.5">
{STEPS.map((label, i) => (
<div key={label} className="flex items-center gap-1.5">
{i > 0 && <div className="h-px w-3 bg-border" />}
<motion.div
animate={{
width: i === step ? 64 : 24,
backgroundColor: i === step
? 'hsl(var(--accent))'
: i < step
? 'hsl(var(--accent) / 0.6)'
: 'hsl(var(--border))',
}}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="h-1.5 rounded-full"
/>
</div>
))}
</div>
<div className="w-[88px] text-right">
<span className="text-xs text-muted tabular">
{step + 1} / {STEPS.length}
</span>
</div>
</header>
{/* 步骤内容 */}
<main className="relative z-10 flex-1 flex items-center justify-center px-6 py-10">
<div className="w-full max-w-xl">
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -24 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
>
{step === 0 && <WelcomeStep onNext={() => setStep(1)} onSkip={finish} />}
{step === 1 && (
<KeyStep onNext={() => setStep(2)} onSkip={() => setStep(2)} onBack={() => setStep(0)} />
)}
{step === 2 && <ResultStep onNext={() => setStep(3)} onBack={() => setStep(1)} />}
{step === 3 && <FinishStep onNext={finish} onBack={() => setStep(2)} pending={complete.isPending} />}
</motion.div>
</AnimatePresence>
</div>
</main>
</div>
)
}
// ===== Step 0: 欢迎 =====
function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) {
return (
<div className="text-center">
{/* 品牌 badge */}
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="mx-auto w-fit rounded-2xl p-4 border border-border"
style={{ background: `linear-gradient(135deg, ${BRAND}22, transparent)` }}
>
<Sparkles className="h-8 w-8" style={{ color: BRAND }} />
</motion.div>
<h1 className="mt-6 text-3xl font-bold text-foreground tracking-tight">
使 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">
{HIGHLIGHTS.map((h, i) => (
<motion.div
key={h.title}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05 * 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"
>
<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>
</motion.div>
))}
</div>
<div className="mt-8 flex items-center justify-center gap-3">
<button
onClick={onNext}
className="inline-flex items-center gap-2 px-6 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 transition-all"
>
<ArrowRight className="h-4 w-4" />
</button>
<button
onClick={onSkip}
className="px-4 h-11 rounded-xl text-sm text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
</button>
</div>
</div>
)
}
// ===== Step 1: 输入数据源 Key =====
function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () => void; onBack: () => void }) {
const qc = useQueryClient()
const settings = useSettings()
const [keyInput, setKeyInput] = useState('')
const [revealing, setRevealing] = useState(false)
const [saved, setSaved] = useState(false)
const save = useMutation({
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
if (data.ok) {
// 仅当 key 有效(被存储)时才进入下一步看探测结果
setSaved(true)
setTimeout(() => onNext(), 600)
}
// ok=false(key 无效):不进入下一步,错误提示由 save.error / save.data 渲染
},
})
// 已配置 key —— 免费档或付费档都算(只要不是无档 none)
const alreadyHasKey = settings.data?.mode !== 'none' && settings.data?.mode !== undefined
return (
<div>
<div className="flex items-center gap-2.5">
<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>
</div>
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
Key 使 Key <span className="font-medium text-foreground"> </span>
使K; Key
</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>{' '}
,使
</div>
{/* Key 已配置提示 */}
{alreadyHasKey && !save.isPending && (
<div className="mt-4 flex items-start gap-2 rounded-btn border border-bear/30 bg-bear/10 px-3 py-2.5 text-xs text-bear">
<CheckCircle2 className="h-3.5 w-3.5 mt-px shrink-0" />
<span>
Key(<span className="font-mono">{settings.data?.tickflow_api_key_masked}</span>)
, Key
</span>
</div>
)}
{/* 输入 */}
<form
onSubmit={(e) => {
e.preventDefault()
if (keyInput.trim()) save.mutate()
}}
className="mt-4 space-y-2"
>
<div className="relative">
<input
type={revealing ? 'text' : 'password'}
placeholder={alreadyHasKey ? '粘贴新 Key 替换当前' : '粘贴数据源 API Key'}
value={keyInput}
onChange={(e) => {
setKeyInput(e.target.value)
if (saved) setSaved(false)
}}
className="w-full px-3 py-2.5 pr-9 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"
/>
<button
type="button"
onClick={() => setRevealing((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-foreground transition-colors"
tabIndex={-1}
aria-label={revealing ? '隐藏' : '显示'}
>
{revealing ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{/* 保存中提示 */}
{save.isPending && (
<div className="flex items-start gap-1.5 rounded-btn border border-warning/30 bg-warning/10 px-3 py-2 text-[11px] leading-snug text-warning">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span> Key ,</span>
</div>
)}
{save.isError && (
<div className="text-xs text-danger">:{String((save.error as any).message)}</div>
)}
{/* 无效 key —— 探测失败(key 无效/乱填)未存储,提示用户 */}
{save.data && !save.data.ok && (
<div className="flex items-start gap-1.5 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-[11px] leading-snug text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>
{save.data.reason === 'invalid'
? 'Key 无效或已过期,请检查后重试(未保存该 Key)。'
: save.data.error ?? '保存失败'}
</span>
</div>
)}
</form>
{/* 底部操作 */}
<div className="mt-6 flex items-center justify-between">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-btn text-sm text-secondary hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<button
onClick={onSkip}
disabled={save.isPending}
className="px-4 h-9 rounded-btn text-sm text-secondary hover:text-foreground transition-colors disabled:opacity-50"
>
{alreadyHasKey ? '下一步' : '暂不配置'}
</button>
<button
onClick={() => keyInput.trim() && save.mutate()}
disabled={save.isPending || !keyInput.trim()}
className="inline-flex items-center gap-2 px-5 h-9 rounded-xl bg-accent text-white text-sm font-semibold 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>
</div>
</div>
)
}
// ===== Step 2: 能力探测结果 =====
function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void }) {
const settings = useSettings()
const caps = useCapabilities()
// 是否配置成功 —— 免费档(free)或付费档(api_key)都算;无档(none)算未配置
const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key'
const capList = caps.data ? Object.entries(caps.data.capabilities) : []
return (
<div>
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-accent/10 p-2">
<ScanSearch className="h-4 w-4 text-accent" />
</div>
<h2 className="text-xl font-bold text-foreground"></h2>
</div>
{hasKey ? (
<>
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
Key ,
<span className="text-foreground font-medium"> </span>
Key
</p>
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-5">
<div className="flex items-baseline justify-between">
<span className="text-[10px] uppercase tracking-widest text-muted"></span>
<span className="font-mono text-2xl font-bold tracking-tight text-foreground">
{caps.data?.label ?? settings.data?.tier_label ?? '—'}
</span>
</div>
{caps.isLoading ? (
<div className="mt-4 flex items-center gap-2 text-xs text-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : capList.length > 0 ? (
<div className="mt-4 grid grid-cols-1 gap-1.5">
{capList.slice(0, 8).map(([cap]) => {
const meta = CAP_LABELS[cap]
return (
<div key={cap} className="flex items-center gap-2 text-xs">
<CheckCircle2 className="h-3.5 w-3.5 text-bear shrink-0" />
<span className="text-foreground">{meta?.name ?? cap}</span>
</div>
)
})}
{capList.length > 8 && (
<div className="text-[11px] text-muted pl-5"> {capList.length} </div>
)}
</div>
) : (
<div className="mt-4 text-xs text-muted"></div>
)}
</div>
</>
) : (
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6 text-center">
<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>
<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>
</p>
</div>
)}
{/* 底部操作 */}
<div className="mt-6 flex items-center justify-between">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-btn text-sm text-secondary hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
onClick={onNext}
className="inline-flex items-center gap-2 px-5 h-9 rounded-xl bg-accent text-white text-sm font-semibold hover:bg-accent/90 transition-colors"
>
<ArrowRight className="h-4 w-4" />
</button>
</div>
</div>
)
}
// ===== Step 3: 完成 =====
function FinishStep({ onNext, onBack, pending }: { onNext: () => void; onBack: () => void; pending: boolean }) {
const tips = [
{ icon: ScanSearch, text: '在「选股」页用内置策略一键扫描全市场' },
{ icon: BellRing, text: '在「监控」页设置条件或策略告警,盘中实时推送' },
{ icon: ShieldCheck, text: '在「回测」页用历史数据验证策略表现' },
]
return (
<div className="text-center">
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="mx-auto w-fit"
>
<div
className="relative rounded-2xl p-5 border border-border"
style={{ background: `linear-gradient(135deg, ${BRAND}22, transparent)` }}
>
<CheckCircle2 className="h-12 w-12 text-bear" />
{/* 光晕脉冲 */}
<motion.div
animate={{ scale: [1, 1.4], opacity: [0.4, 0] }}
transition={{ duration: 1.8, repeat: Infinity, ease: 'easeOut' }}
className="absolute inset-5 rounded-full bg-bear/30"
/>
</div>
</motion.div>
<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>
</p>
{/* 快速上手提示 */}
<div className="mt-6 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 }}
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">
<t.icon className="h-3.5 w-3.5 text-accent" />
</div>
<span className="text-xs text-secondary">{t.text}</span>
</motion.div>
))}
</div>
{/* 底部操作 */}
<div className="mt-8 flex items-center justify-between">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 px-3 h-10 rounded-btn text-sm text-secondary hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
onClick={onNext}
disabled={pending}
className="inline-flex items-center gap-2 px-6 h-10 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"
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{pending ? '正在进入…' : '进入面板'}
</button>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More