初始化工程
This commit is contained in:
@@ -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}</>
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
宽度 40–300 / 高度 32–200 / 天数 1–60,越界自动钳制到边界
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>正在获取最近5日分钟K…</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">是否立即获取最近5日分钟K?</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`} />
|
||||
)
|
||||
}
|
||||
@@ -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 limits;tierReq → 无权限时显示的档位要求
|
||||
// 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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: [] })
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user