@@ -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-1.5 pl-0.5">
|
||||
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
|
||||
{/* message 已含「条件摘要 · 现价 · 涨跌幅」(后端生成), 直接展示避免重复 */}
|
||||
{ev.message && <span className="text-[11px] text-foreground/70 truncate flex-1">{ev.message}</span>}
|
||||
</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,592 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts, EChartsOption } from 'echarts'
|
||||
import type { MinuteKlineRow } from '@/lib/api'
|
||||
|
||||
type YMode = 'adaptive' | 'limit'
|
||||
|
||||
const THEME = {
|
||||
line: '#3B82F6',
|
||||
areaFill: 'rgba(59,130,246,0.40)',
|
||||
avgLine: '#F59E0B',
|
||||
refLine: 'rgba(255,255,255,0.25)',
|
||||
volUp: 'rgba(240,68,56,0.6)',
|
||||
volDown: 'rgba(18,183,106,0.6)',
|
||||
text: '#A1A1AA',
|
||||
grid: 'rgba(255,255,255,0.04)',
|
||||
border: '#27272A',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
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, 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: 'rgba(39,39,42,0.9)',
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
borderWidth: 1,
|
||||
padding: [2, 5],
|
||||
color: '#A1A1AA',
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
},
|
||||
crossStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
|
||||
lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
|
||||
},
|
||||
},
|
||||
axisPointer: {
|
||||
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: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
|
||||
label: {
|
||||
show: true,
|
||||
backgroundColor: 'rgba(39,39,42,0.9)',
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
borderWidth: 1,
|
||||
padding: [2, 4],
|
||||
color: '#A1A1AA',
|
||||
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: 'rgba(255,255,255,0.04)' },
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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, symbol, showLimitLines, showAvgLine), true)
|
||||
} else {
|
||||
chart.clear()
|
||||
}
|
||||
}, [data, prevClose, height, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine])
|
||||
|
||||
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: 'rgba(39,39,42,0.6)' }}>
|
||||
{/* 第一行: 日期 + OHLC */}
|
||||
<div className="flex items-center gap-x-2 px-2 font-mono text-[11px] select-none flex-wrap" style={{ height: 20 }}>
|
||||
{!d && <span className="text-muted">—</span>}
|
||||
{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)
|
||||
|
||||
// 动态加载端点清单 —— 前端无法跨域直连 tickflow.org,走后端代理
|
||||
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,31 @@
|
||||
import { Clock } from 'lucide-react'
|
||||
import type { StockRef } from '@/lib/useLastStock'
|
||||
|
||||
/**
|
||||
* "上次查看"个股胶囊 —— 显示在 PageHeader 右侧。
|
||||
* 上方名称、下方代码,小字体二排;点击恢复该个股的查看。
|
||||
*/
|
||||
export function LastStockChip({
|
||||
stock,
|
||||
onSelect,
|
||||
}: {
|
||||
stock: StockRef | null
|
||||
onSelect?: (symbol: string, name: string) => void
|
||||
}) {
|
||||
if (!stock) return null
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect?.(stock.symbol, stock.name)}
|
||||
title={`继续查看 ${stock.name}`}
|
||||
className="group inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-elevated/40 px-2 py-1 hover:border-border hover:bg-elevated transition-colors"
|
||||
>
|
||||
<Clock className="h-3 w-3 text-muted shrink-0" />
|
||||
<span className="flex flex-col items-start leading-tight">
|
||||
<span className="text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors max-w-[7em] truncate">
|
||||
{stock.name}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-muted">{stock.symbol}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
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 { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
|
||||
import { AiReportBubble } from '@/components/financials/AiReportBubble'
|
||||
import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost'
|
||||
import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble'
|
||||
import {
|
||||
useCapabilities,
|
||||
useSettings,
|
||||
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,
|
||||
Loader2,
|
||||
LayoutDashboard,
|
||||
Tags,
|
||||
TrendingUp,
|
||||
Flame,
|
||||
BarChart3,
|
||||
Sparkles,
|
||||
Layers3,
|
||||
Landmark,
|
||||
Cable,
|
||||
RadioTower,
|
||||
CheckCircle2,
|
||||
BookOpenCheck,
|
||||
ExternalLink,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { api, type IndexQuote } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
|
||||
|
||||
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
|
||||
const BRAND = '#8B5CF6'
|
||||
const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA'
|
||||
|
||||
const CORE_INDEXES = [
|
||||
{ symbol: '000001.SH', name: '上证指数' },
|
||||
{ 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: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
||||
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
|
||||
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
||||
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
|
||||
{ to: '/financials', label: '财务分析', icon: FileText },
|
||||
{ to: '/monitor', label: '监控中心', icon: RadioTower },
|
||||
{ to: '/review', label: '复盘', icon: BookOpenCheck },
|
||||
{ to: '/indices', label: '指数', icon: BarChart3 },
|
||||
{ to: '/trading', label: '交易', icon: Cable },
|
||||
{ to: '/data', label: '数据', icon: Database },
|
||||
] as const
|
||||
|
||||
function fmtIndexValue(v: number | null | undefined) {
|
||||
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 档显示英文「None」,无 label 时也显示「None」
|
||||
const displayLabel = isNone ? 'None' : (label || 'None')
|
||||
|
||||
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">TickFlow</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 { data: caps } = useCapabilities()
|
||||
const { data: settingsState } = useSettings()
|
||||
const { data: versionData } = useVersion()
|
||||
const { data: prefs } = usePreferences()
|
||||
// poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE)
|
||||
const { data: quoteStatus } = useQuoteStatus({ poll: true })
|
||||
const { data: analysisMenus } = useQuery({
|
||||
queryKey: QK.analysisMenus,
|
||||
queryFn: api.analysisMenus,
|
||||
})
|
||||
|
||||
// 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈
|
||||
const { data: pipelineJobs } = useQuery({
|
||||
queryKey: QK.pipelineJobs,
|
||||
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
|
||||
// Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
|
||||
const [dismissFreeHint, setDismissFreeHint] = useState(false)
|
||||
const indicesPinned = prefs?.indices_nav_pinned ?? true
|
||||
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol)
|
||||
const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol))
|
||||
// 卡片数据:固定显示时也拉取(即使实时行情关闭)
|
||||
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
|
||||
const tier = tierRank(caps?.label ?? '')
|
||||
const isNoneTier = tier < 0
|
||||
const isWatchlistMode = tier === 0
|
||||
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
|
||||
|
||||
// 轮询触发记录总数 → 更新监控中心徽标 (每 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,
|
||||
})
|
||||
const freshTier = tierRank(fresh.label ?? '')
|
||||
if (freshTier < 0) return
|
||||
if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
|
||||
navigate('/watchlist')
|
||||
return
|
||||
}
|
||||
}
|
||||
await toggleQuote.mutateAsync(enabled)
|
||||
// 仅在交易时段立即获取一次行情
|
||||
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="shrink-0 drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]"
|
||||
style={{ color: BRAND }}
|
||||
/>
|
||||
<div
|
||||
className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight"
|
||||
style={{ textShadow: `0 0 10px ${BRAND}44` }}
|
||||
>
|
||||
<div>TickFlow</div>
|
||||
<div>Stock Panel</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
|
||||
Quant · Terminal
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-3 h-px"
|
||||
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
||||
/>
|
||||
|
||||
<TierBadge
|
||||
label={caps?.label ?? ''}
|
||||
hasKey={settingsState?.mode !== 'none'}
|
||||
/>
|
||||
<AIConfigBadge
|
||||
configured={settingsState?.ai_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>
|
||||
{/* 个股分析 Beta 标识 */}
|
||||
{(to === '/stock-analysis' || to === '/review') && (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
|
||||
{to === '/data' && isDataSyncing && (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
||||
)}
|
||||
{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">
|
||||
{isNoneTier ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary truncate">实时行情</span>
|
||||
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
|
||||
Free+
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[10px] leading-snug text-muted">
|
||||
免费注册
|
||||
<a
|
||||
href={TICKFLOW_REGISTER_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mx-1 inline-flex items-baseline gap-0.5 text-accent/80 hover:text-accent hover:underline"
|
||||
>
|
||||
TickFlow
|
||||
<ExternalLink className="h-2.5 w-2.5 self-center" />
|
||||
</a>
|
||||
开启个股监控
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Starter+ — 开关 + 跳转设置 */
|
||||
<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">
|
||||
实时行情 · {realtimeModeLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => navigate('/settings?tab=monitoring')}
|
||||
className="text-secondary hover:text-foreground transition-colors shrink-0"
|
||||
title="实时监控设置"
|
||||
>
|
||||
<Settings 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 && !isNoneTier && (
|
||||
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
|
||||
{isWatchlistMode && !dismissFreeHint && (
|
||||
<div className="flex items-start gap-1 text-amber-400/80">
|
||||
<span className="flex-1">监控自选股前 5 只,全市场监控需 Starter+</span>
|
||||
<button
|
||||
onClick={() => setDismissFreeHint(true)}
|
||||
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
|
||||
title="关闭提示"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isRunning && isTrading ? (
|
||||
<div className="text-accent">行情运行中</div>
|
||||
) : realtimeEnabled && !isTrading ? (
|
||||
<div className="text-warning/70">非交易时段,将在交易时间自动开启</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{showSidebarQuotes && !isWatchlistMode && !isNoneTier && (
|
||||
<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 />
|
||||
<AiAnalysisHost />
|
||||
<AiReportBubble />
|
||||
<StockAnalysisHost />
|
||||
<StockAnalysisBubble />
|
||||
</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="TickFlow 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,445 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Repeat, Sparkles, ArrowDownUp, RefreshCw, AlertCircle } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fmtPct } from '@/lib/format'
|
||||
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_DAYS = 12
|
||||
const ROW_HEIGHT = 30 // 每行高度(px), 与单元格样式配合
|
||||
const OVERSCAN = 8 // 上下额外渲染行数, 减少滚动时的白屏闪烁
|
||||
const MIN_DAYS = 7
|
||||
const MAX_DAYS = 30
|
||||
|
||||
// 涨幅 → 背景色梯度(A 股语义: 红涨绿跌)。强度越大色越深, 一眼看出强势/弱势概念
|
||||
function pctBgClass(pct: number): string {
|
||||
if (pct >= 0.05) return 'bg-bull/25'
|
||||
if (pct >= 0.03) return 'bg-bull/18'
|
||||
if (pct >= 0.01) return 'bg-bull/10'
|
||||
if (pct > -0.01) return ''
|
||||
if (pct > -0.03) return 'bg-bear/10'
|
||||
if (pct > -0.05) return 'bg-bear/18'
|
||||
return 'bg-bear/25'
|
||||
}
|
||||
|
||||
// 把 "2026-07-01" 格式化成 "7/01" 紧凑显示(表头窄列)
|
||||
function shortDate(s: string): string {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
|
||||
if (!m) return s
|
||||
return `${Number(m[2])}/${m[3]}`
|
||||
}
|
||||
|
||||
// 排名 → 前景色(A 股语义: 红=强, 绿=弱)。前 10 红, 后 10 绿, 中间默认强调色。
|
||||
// total 兜底: 概念总数未知时只判前 10, 不判后 10。
|
||||
function rankColorClass(rank: number, total: number): string {
|
||||
if (rank <= 10) return 'text-bull'
|
||||
if (total > 20 && rank > total - 10) return 'text-bear'
|
||||
return 'text-accent'
|
||||
}
|
||||
|
||||
export function RpsRotationDialog({ onClose }: Props) {
|
||||
const [days, setDays] = useState(DEFAULT_DAYS)
|
||||
const [reversed, setReversed] = useState(false) // false=高→低, true=低→高
|
||||
const [selected, setSelected] = useState<string | null>(null) // 点中的概念名, 高亮追踪
|
||||
|
||||
// ---- AI 轮动分析状态 (组件内, 不建全局 store: 切页即关对话框) ----
|
||||
const [analysis, setAnalysis] = useState('') // 累积的 Markdown 报告
|
||||
const [analyzing, setAnalyzing] = useState(false) // 生成中
|
||||
const [analysisError, setAnalysisError] = useState('') // 错误信息
|
||||
const [analysisMeta, setAnalysisMeta] = useState<{ summary?: string } | null>(null)
|
||||
const [focus, setFocus] = useState('') // 用户追加的关注点
|
||||
|
||||
const runAnalysis = useCallback(async (daysParam: number, focusParam: string) => {
|
||||
setAnalyzing(true)
|
||||
setAnalysis('')
|
||||
setAnalysisError('')
|
||||
setAnalysisMeta(null)
|
||||
try {
|
||||
for await (const ev of api.rotationAnalyzeStream(daysParam, focusParam)) {
|
||||
if (ev.type === 'meta') setAnalysisMeta({ summary: ev.summary })
|
||||
else if (ev.type === 'delta') setAnalysis(a => a + (ev.content ?? ''))
|
||||
else if (ev.type === 'error') setAnalysisError(ev.message ?? '未知错误')
|
||||
// done: 无操作
|
||||
}
|
||||
} catch (e) {
|
||||
setAnalysisError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 数据请求: React Query 缓存, 同 days 5 分钟内重开秒开
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: QK.rpsRotation(days),
|
||||
queryFn: () => api.rpsRotation(days),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const dates = data?.dates ?? []
|
||||
const columns = data?.columns ?? {}
|
||||
const conceptCount = data?.concept_count ?? 0
|
||||
|
||||
// 行数 = 最长那列的长度(理论上每天概念数应一致, 取最大兜底)
|
||||
const rowCount = useMemo(
|
||||
() => dates.reduce((m, d) => Math.max(m, columns[d]?.length ?? 0), 0),
|
||||
[dates, columns],
|
||||
)
|
||||
|
||||
// 行索引: 翻转时不重排数据, 只翻转访问索引(省一次大数组操作)
|
||||
const getRowIndex = useCallback(
|
||||
(displayIdx: number) => (reversed ? rowCount - 1 - displayIdx : displayIdx),
|
||||
[reversed, rowCount],
|
||||
)
|
||||
|
||||
// ---- 手写虚拟滚动 ----
|
||||
// 监听滚动容器 scrollTop, 只渲染 [firstIdx, lastIdx] 范围内的行。
|
||||
// 387 行只画可视的 ~25 行 + overscan, DOM 恒定 ~30 行 × N 列, 滚动 60fps。
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
// AI 报告区滚动容器: 流式生成时自动滚到底部
|
||||
const analysisRef = useRef<HTMLDivElement>(null)
|
||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 25 })
|
||||
|
||||
// 流式生成中: analysis 每次追加都把报告区滚到底部, 跟踪最新文字
|
||||
useEffect(() => {
|
||||
if (!analyzing) return
|
||||
const el = analysisRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [analysis, analyzing])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const scrollTop = el.scrollTop
|
||||
const viewportH = el.clientHeight
|
||||
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN)
|
||||
const end = Math.min(rowCount, Math.ceil((scrollTop + viewportH) / ROW_HEIGHT) + OVERSCAN)
|
||||
setVisibleRange(prev => (prev.start === start && prev.end === end ? prev : { start, end }))
|
||||
}, [rowCount])
|
||||
|
||||
useEffect(() => {
|
||||
// rowCount 变化(切天数/数据到达)时重算可视范围
|
||||
handleScroll()
|
||||
}, [handleScroll, rowCount])
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
// 选中概念的追踪行: 找出它在每个日期列的(排名, 涨幅)。
|
||||
// 每列已按涨幅降序排好, 故排名 = 该概念在数组里的索引 + 1。
|
||||
// 未入选该日(概念当天无数据)显示空, 便于横向看排名变化。
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!selected) return null
|
||||
const cells: ({ rank: number; pct: number } | null)[] = []
|
||||
for (const d of dates) {
|
||||
const col = columns[d] ?? []
|
||||
const idx = col.findIndex(([name]) => name === selected)
|
||||
cells.push(idx >= 0 ? { rank: idx + 1, pct: col[idx][1] } : null)
|
||||
}
|
||||
return cells
|
||||
}, [selected, dates, columns])
|
||||
|
||||
const renderRows = useMemo(() => {
|
||||
const rows: JSX.Element[] = []
|
||||
for (let displayIdx = visibleRange.start; displayIdx < visibleRange.end; displayIdx++) {
|
||||
const rawIdx = getRowIndex(displayIdx)
|
||||
const cells = dates.map((d) => {
|
||||
const cell = columns[d]?.[rawIdx]
|
||||
if (!cell) {
|
||||
return (
|
||||
<td key={d} className="px-2 py-1 text-center text-muted/40">
|
||||
<span className="text-[10px]">—</span>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
const [name, pct] = cell
|
||||
const isSelected = selected === name
|
||||
return (
|
||||
<td
|
||||
key={d}
|
||||
onClick={() => setSelected(prev => prev === name ? null : name)}
|
||||
className={cn(
|
||||
'px-2 py-1 cursor-pointer whitespace-nowrap text-center align-middle transition-colors',
|
||||
pctBgClass(pct),
|
||||
isSelected && 'ring-1 ring-inset ring-accent bg-accent/20',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-0.5 leading-tight">
|
||||
<span className={cn(
|
||||
'text-[11px] max-w-[84px] truncate',
|
||||
isSelected ? 'text-accent font-medium' : 'text-secondary',
|
||||
)} title={name}>{name}</span>
|
||||
<span className={cn(
|
||||
'text-[10px] tabular-nums',
|
||||
pct > 0 ? 'text-bull' : pct < 0 ? 'text-bear' : 'text-muted',
|
||||
)}>{fmtPct(pct)}</span>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})
|
||||
rows.push(
|
||||
<tr
|
||||
key={displayIdx}
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
className="border-b border-border/30"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-surface px-2 text-center text-[10px] text-muted tabular-nums border-r border-border/40">
|
||||
{displayIdx + 1}
|
||||
</td>
|
||||
{cells}
|
||||
</tr>,
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}, [visibleRange, getRowIndex, dates, columns, selected])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-[92vw] max-w-[1100px] h-[88vh] bg-surface border border-border rounded-card shadow-xl flex flex-col"
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium text-foreground">概念涨幅轮动</span>
|
||||
<span className="text-[11px] text-muted">
|
||||
{conceptCount > 0 ? `${dates.length} 天 · ${conceptCount} 个概念` : '暂无数据'}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
|
||||
<X className="h-4 w-4 text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 上半区: AI 轮动分析 */}
|
||||
<div className="shrink-0 border-b border-border flex flex-col max-h-[42%]">
|
||||
{/* 标题栏: 标题 + meta 摘要 + focus 输入 + 触发按钮 */}
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-elevated/30 shrink-0">
|
||||
<Sparkles className={cn('h-3.5 w-3.5 text-accent/60', analyzing && 'animate-pulse')} />
|
||||
<span className="text-[11px] text-muted shrink-0">AI 轮动分析</span>
|
||||
{analysisMeta?.summary && (
|
||||
<span className="text-[11px] text-accent/80 truncate">{analysisMeta.summary}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={focus}
|
||||
onChange={e => setFocus(e.target.value)}
|
||||
placeholder="关注点(可选)"
|
||||
disabled={analyzing}
|
||||
className="w-28 px-2 py-0.5 text-[11px] bg-elevated/50 border border-border rounded-btn text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/40 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => runAnalysis(days, focus)}
|
||||
disabled={analyzing}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] transition-colors cursor-pointer border',
|
||||
analyzing
|
||||
? 'opacity-60 cursor-not-allowed border-border text-muted'
|
||||
: 'bg-accent/10 text-accent border-accent/30 hover:bg-accent/20',
|
||||
)}
|
||||
>
|
||||
{analyzing
|
||||
? <><RefreshCw className="h-3 w-3 animate-spin" />分析中</>
|
||||
: analysis
|
||||
? <><RefreshCw className="h-3 w-3" />重新分析</>
|
||||
: <><Sparkles className="h-3 w-3" />生成分析</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 报告内容区: 四态渲染 */}
|
||||
<div ref={analysisRef} className="flex-1 min-h-0 overflow-auto">
|
||||
{analysisError ? (
|
||||
<div className="flex items-center gap-2 px-4 py-4 text-[11px] text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{analysisError}</span>
|
||||
<button
|
||||
onClick={() => runAnalysis(days, focus)}
|
||||
className="ml-auto text-accent hover:underline shrink-0"
|
||||
>重试</button>
|
||||
</div>
|
||||
) : analysis || analyzing ? (
|
||||
<div className="px-4 py-2.5 text-[12px] leading-relaxed">
|
||||
<MarkdownRenderer content={analysis} />
|
||||
{analyzing && (
|
||||
<span className="inline-block w-1.5 h-3.5 bg-accent animate-pulse align-middle ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-4 text-center text-[11px] text-muted/60">
|
||||
点击「生成分析」,AI 将从主线研判 / 新晋强势 / 退潮预警 / 机构vs游资 等角度分析最近 {days} 天的概念轮动
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted">天数</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_DAYS}
|
||||
max={MAX_DAYS}
|
||||
step={1}
|
||||
value={days}
|
||||
onChange={e => setDays(Number(e.target.value))}
|
||||
className="w-24 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] text-secondary tabular-nums w-5">{days}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setReversed(r => !r)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] transition-colors cursor-pointer border',
|
||||
reversed
|
||||
? 'bg-accent/10 text-accent border-accent/30'
|
||||
: 'border-border text-muted hover:text-secondary hover:bg-elevated',
|
||||
)}
|
||||
title="翻转排序(高↔低)"
|
||||
>
|
||||
<ArrowDownUp className="h-3 w-3" />
|
||||
{reversed ? '低→高' : '高→低'}
|
||||
</button>
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="text-[11px] text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
取消追踪「{selected}」
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下半区: 涨幅轮动矩阵(虚拟滚动) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-[11px] text-danger">
|
||||
加载失败,请稍后重试
|
||||
</div>
|
||||
) : rowCount === 0 ? (
|
||||
<div className="flex items-center justify-center py-16 text-[11px] text-muted">
|
||||
暂无概念数据,请先在「概念分析」页配置并获取概念数据源
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-auto"
|
||||
>
|
||||
<table className="min-w-full border-collapse">
|
||||
{/* 表头: 日期列, 最新在最左 */}
|
||||
<thead className="sticky top-0 z-20 bg-surface">
|
||||
<tr>
|
||||
<th className="sticky left-0 z-30 bg-surface px-2 py-1.5 text-[10px] font-normal text-muted border-b border-r border-border/40">
|
||||
#
|
||||
</th>
|
||||
{dates.map(d => (
|
||||
<th
|
||||
key={d}
|
||||
className="px-2 py-1.5 text-[10px] font-normal text-muted border-b border-border/40 whitespace-nowrap text-center"
|
||||
title={d}
|
||||
>
|
||||
{shortDate(d)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{/* 选中概念追踪行: 在日期表头下方单独一行, 横向展示它在各日的排名+涨幅 */}
|
||||
<AnimatePresence>
|
||||
{selected && selectedRow && (
|
||||
<motion.tr
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="border-b border-accent/20 bg-accent/5"
|
||||
>
|
||||
<td className="sticky left-0 z-30 bg-surface px-2 py-1 text-center border-r border-border/40">
|
||||
<span className="text-[10px] text-accent truncate block max-w-[44px]" title={selected}>
|
||||
{selected}
|
||||
</span>
|
||||
</td>
|
||||
{selectedRow.map((cell, i) => (
|
||||
<td key={i} className="px-2 py-1 text-center whitespace-nowrap align-middle">
|
||||
{cell ? (
|
||||
<div className="flex flex-col items-center gap-0.5 leading-tight">
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium tabular-nums',
|
||||
rankColorClass(cell.rank, conceptCount),
|
||||
)}>
|
||||
#{cell.rank}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-[10px] tabular-nums',
|
||||
cell.pct > 0 ? 'text-bull' : cell.pct < 0 ? 'text-bear' : 'text-muted',
|
||||
)}>
|
||||
{fmtPct(cell.pct)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted/40">—</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</motion.tr>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* 顶部占位: 把滚动位置撑起来 */}
|
||||
{visibleRange.start > 0 && (
|
||||
<tr style={{ height: visibleRange.start * ROW_HEIGHT }}>
|
||||
<td colSpan={dates.length + 1} />
|
||||
</tr>
|
||||
)}
|
||||
{renderRows}
|
||||
{/* 底部占位 */}
|
||||
{visibleRange.end < rowCount && (
|
||||
<tr style={{ height: (rowCount - visibleRange.end) * ROW_HEIGHT }}>
|
||||
<td colSpan={dates.length + 1} />
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="px-4 py-1.5 border-t border-border shrink-0">
|
||||
<span className="text-[10px] text-muted">
|
||||
每列各自按当日涨幅排序 · 点击单元格追踪概念在各日的排名变化
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -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-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-500/10 border border-yellow-500/30 cursor-help transition-all hover:bg-yellow-500/20 hover:border-yellow-500/50"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-yellow-500" />
|
||||
<span className="text-[10px] font-medium text-yellow-600 dark:text-yellow-500 leading-none">{label}</span>
|
||||
<HelpCircle className="h-3 w-3 text-yellow-500/70 group-hover:text-yellow-500 transition-colors" />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showHint && (
|
||||
<>
|
||||
<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 表示本地无数据且 TickFlow 也拉不到 (停牌/复牌延迟/非交易日)
|
||||
// 此时不弹"是否获取"询问窗, 只做静态提示, 避免误导用户去拉明知拉不到的数据
|
||||
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,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,106 @@
|
||||
/**
|
||||
* 回测预热期徽标 — 点击弹出说明气泡。
|
||||
*
|
||||
* 解释「回测开头几个月没有交易」这一高频疑问: 技术指标需要历史数据预热,
|
||||
* 系统会自动在回测起点之前多取约 120 天 (≈4 个月) 数据; 若本地数据恰好从
|
||||
* 起点才开始, 开头几个月指标算不出、信号不触发, 属正常现象。
|
||||
*
|
||||
* 实现要点:
|
||||
* - 点击触发 (非 hover), 移动端友好
|
||||
* - 用 createPortal 渲染到 body, 绕开父容器 overflow 裁剪 (回测配置面板有 overflow-y-auto)
|
||||
* - 全屏透明遮罩点击关闭 + ESC 关闭
|
||||
* - 气泡位置 = 锚点 rect 实时计算, 自动判断向左/向右展开避免溢出屏幕
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
interface Pos { top: number; left: number }
|
||||
|
||||
export function WarmupBadge() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||
const [pos, setPos] = useState<Pos>({ top: 0, left: 0 })
|
||||
|
||||
// 打开时根据锚点 rect 计算气泡位置 (向下方弹出)
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !anchorRef.current) return
|
||||
const rect = anchorRef.current.getBoundingClientRect()
|
||||
const POPUP_W = 272
|
||||
const GAP = 8
|
||||
// 优先左对齐锚点; 右侧不够则右对齐; 兜底贴左边
|
||||
let left = rect.left
|
||||
if (left + POPUP_W > window.innerWidth - 8) {
|
||||
left = rect.right - POPUP_W
|
||||
}
|
||||
left = Math.max(8, left)
|
||||
setPos({ top: rect.bottom + GAP, left })
|
||||
}, [open])
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="inline-flex items-center gap-0.5 rounded-full px-1.5 text-[10px] text-amber-500/70 transition-colors hover:bg-amber-400/10 hover:text-amber-500"
|
||||
title="为什么开头可能没交易?"
|
||||
>
|
||||
<Info className="h-3 w-3" strokeWidth={1.5} />
|
||||
预热 ≥120 天
|
||||
</button>
|
||||
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* 全屏透明遮罩: 点击关闭 */}
|
||||
<div
|
||||
className="fixed inset-0 z-[60]"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
{/* 气泡: 绝对定位到 body, 绕开 overflow 裁剪 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, width: 272 }}
|
||||
className="z-[70] rounded-btn border border-border bg-surface p-3 text-[11px] leading-relaxed text-secondary shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-1.5 font-medium text-foreground">为什么开头几个月可能没有交易?</div>
|
||||
<p className="text-muted">
|
||||
技术指标 (MA / MACD / RSI 等) 需要历史数据才能算出。系统会自动在回测起点之前多取约
|
||||
<span className="font-medium text-amber-300"> 120 天 (≈4 个月)</span> 数据做预热。
|
||||
</p>
|
||||
<p className="mt-1.5 text-muted">
|
||||
若本地数据恰好从回测起点才开始, 开头几个月指标算不出、信号不触发,
|
||||
<span className="text-secondary"> 属正常现象, 不是 bug</span>。等数据攒够后自然开始产生交易。
|
||||
</p>
|
||||
<div className="mt-2 border-t border-border/60 pt-2 text-muted">
|
||||
<span className="text-secondary">解决:</span> 把历史数据补到回测起点之前至少半年, 或把起点往后挪。
|
||||
</div>
|
||||
{/* 小箭头指向锚点 */}
|
||||
<div
|
||||
className="absolute -top-1 h-2 w-2 rotate-45 border-l border-t border-border bg-surface"
|
||||
style={{ left: 12 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* 概念/行业分析 — 共享 UI 组件
|
||||
*
|
||||
* 包含:
|
||||
* - AnalysisConfigDialog: 字段配置弹窗
|
||||
* - DimensionHeatmap: 维度热力图块
|
||||
* - OverviewStatCards: 总览统计卡片
|
||||
* - DimensionGroupSidebar: 维度分组侧边栏
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
ChevronDown,
|
||||
Database,
|
||||
DownloadCloud,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Tags,
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置预设 (概念/行业) 数据获取空状态。
|
||||
*
|
||||
* 当检测到内置预设存在但无数据时, 展示图标 + 提示 + 「获取数据」按钮,
|
||||
* 让用户手动触发拉取 (POST /api/ext-data/presets/{id}/fetch), 而非自动拉取。
|
||||
*/
|
||||
export function PresetFetchState({
|
||||
title,
|
||||
hint,
|
||||
isLoading,
|
||||
error,
|
||||
onFetch,
|
||||
}: {
|
||||
title: string
|
||||
hint: string
|
||||
isLoading: boolean
|
||||
error: unknown
|
||||
onFetch: () => void
|
||||
}) {
|
||||
const errMsg = error instanceof Error ? error.message : error ? String(error) : ''
|
||||
return (
|
||||
<div className="h-full grid place-items-center px-8 py-16">
|
||||
<div className="text-center max-w-md">
|
||||
<DownloadCloud className="mx-auto h-10 w-10 text-muted" strokeWidth={1.5} />
|
||||
<h2 className="mt-4 text-base font-medium text-foreground">{title}</h2>
|
||||
<p className="mt-2 text-sm text-secondary leading-relaxed">{hint}</p>
|
||||
<button
|
||||
onClick={onFetch}
|
||||
disabled={isLoading}
|
||||
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:brightness-110 disabled:opacity-60"
|
||||
>
|
||||
{isLoading ? (
|
||||
<><RefreshCw className="h-4 w-4 animate-spin" /> 获取中...</>
|
||||
) : (
|
||||
<><DownloadCloud className="h-4 w-4" /> 获取数据</>
|
||||
)}
|
||||
</button>
|
||||
{errMsg && (
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-bear">
|
||||
<AlertCircle className="h-3.5 w-3.5" /> {errMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,253 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Check, GripVertical } from 'lucide-react'
|
||||
import { storage } from '@/lib/storage'
|
||||
|
||||
export type CardKey =
|
||||
| 'instruments' | 'daily' | 'adj_factor' | 'enriched'
|
||||
| 'index' | 'etf' | 'minute' | 'financials'
|
||||
|
||||
interface CardDef {
|
||||
key: CardKey
|
||||
label: string
|
||||
desc: string
|
||||
/** 档位能力不足时该卡片是否默认隐藏(减少干扰) */
|
||||
defaultHiddenIfNoCap: boolean
|
||||
/** 无条件默认隐藏(用户可在设置里手动开启) */
|
||||
defaultHidden?: boolean
|
||||
}
|
||||
|
||||
/** 数据画像卡片定义 —— 默认顺序即此数组顺序 */
|
||||
export const DATA_CARD_DEFS: CardDef[] = [
|
||||
{ key: 'instruments', label: '个股维表', desc: 'A 股股票元数据', defaultHiddenIfNoCap: false },
|
||||
{ key: 'daily', label: '日 K', desc: 'A 股日K线数据', defaultHiddenIfNoCap: false },
|
||||
{ key: 'adj_factor', label: '除权因子', desc: '复权计算因子', defaultHiddenIfNoCap: true },
|
||||
{ key: 'enriched', label: 'Enriched', desc: '技术指标计算结果', defaultHiddenIfNoCap: false },
|
||||
{ key: 'index', label: '指数', desc: '主要市场指数日K', defaultHiddenIfNoCap: false },
|
||||
{ key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false, defaultHidden: true },
|
||||
{ key: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true },
|
||||
{ key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true },
|
||||
]
|
||||
|
||||
const DEFAULT_ORDER = DATA_CARD_DEFS.map(d => d.key)
|
||||
/** 恢复默认时显示的卡片数量(按默认顺序取前 N 张) */
|
||||
const DEFAULT_VISIBLE_COUNT = 5
|
||||
|
||||
const CAP_KEY_MAP: Partial<Record<CardKey, string>> = {
|
||||
adj_factor: 'adj_factor',
|
||||
minute: 'kline.minute.batch',
|
||||
financials: 'financial',
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取卡片显隐状态。结合档位能力决定默认值:
|
||||
* - 用户显式设置过 → 用设置值
|
||||
* - 未设置 + defaultHidden → 隐藏(无条件默认隐藏)
|
||||
* - 未设置 + defaultHiddenIfNoCap + 当前无能力 → 隐藏
|
||||
* - 其他 → 显示
|
||||
*/
|
||||
export function getCardVisibility(
|
||||
caps: Record<string, unknown> | undefined,
|
||||
): Record<string, boolean> {
|
||||
const has = (capKey: string) => !capKey || !!caps?.[capKey]
|
||||
const override = storage.dataCardVisible.get({})
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const def of DATA_CARD_DEFS) {
|
||||
if (def.key in override) {
|
||||
result[def.key] = override[def.key]
|
||||
} else if (def.defaultHidden) {
|
||||
result[def.key] = false
|
||||
} else {
|
||||
result[def.key] = def.defaultHiddenIfNoCap ? has(CAP_KEY_MAP[def.key] ?? '') : true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取卡片显示顺序。
|
||||
* - 用户拖拽设置过 → 用设置值(过滤掉已不存在的 key, 补齐新增的 key)
|
||||
* - 未设置 → 用 DATA_CARD_DEFS 默认顺序
|
||||
*/
|
||||
export function getCardOrder(): CardKey[] {
|
||||
const saved = storage.dataCardOrder.get([])
|
||||
if (!saved.length) return [...DEFAULT_ORDER]
|
||||
const known = new Set<CardKey>(DEFAULT_ORDER)
|
||||
const ordered = saved.filter(k => known.has(k as CardKey)) as CardKey[]
|
||||
// 补齐新增的 key(默认顺序里新增的卡片追加到末尾)
|
||||
for (const k of DEFAULT_ORDER) {
|
||||
if (!ordered.includes(k)) ordered.push(k)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
export function PageSettingsModal({
|
||||
caps,
|
||||
}: {
|
||||
caps: Record<string, unknown> | undefined
|
||||
}) {
|
||||
const [visible, setVisible] = useState<Record<string, boolean>>(() => getCardVisibility(caps))
|
||||
const [order, setOrder] = useState<CardKey[]>(() => getCardOrder())
|
||||
|
||||
const persistVisible = (next: Record<string, boolean>) => {
|
||||
setVisible(next)
|
||||
storage.dataCardVisible.set(next)
|
||||
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
|
||||
}
|
||||
const persistOrder = (next: CardKey[]) => {
|
||||
setOrder(next)
|
||||
storage.dataCardOrder.set(next)
|
||||
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
|
||||
}
|
||||
|
||||
const toggle = (key: CardKey) => persistVisible({ ...visible, [key]: !(visible[key] ?? true) })
|
||||
|
||||
const reset = () => {
|
||||
// 恢复默认: 默认顺序 + 仅勾选前 5 张卡片, 其余隐藏
|
||||
const defaultOrder = [...DEFAULT_ORDER]
|
||||
const defaultVisible: Record<string, boolean> = {}
|
||||
defaultOrder.forEach((k, i) => { defaultVisible[k] = i < DEFAULT_VISIBLE_COUNT })
|
||||
storage.dataCardVisible.set(defaultVisible)
|
||||
storage.dataCardOrder.set(defaultOrder)
|
||||
setVisible(defaultVisible)
|
||||
setOrder(defaultOrder)
|
||||
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIdx = order.indexOf(active.id as CardKey)
|
||||
const newIdx = order.indexOf(over.id as CardKey)
|
||||
if (oldIdx < 0 || newIdx < 0) return
|
||||
persistOrder(arrayMove(order, oldIdx, newIdx))
|
||||
}
|
||||
|
||||
// 按 order 排序卡片定义
|
||||
const defByKey = new Map(DATA_CARD_DEFS.map(d => [d.key, d]))
|
||||
const orderedDefs = order.map(k => defByKey.get(k)!).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs text-secondary leading-relaxed">
|
||||
拖动手柄调整卡片顺序,勾选控制显隐。未勾选的卡片将隐藏,不影响数据本身。
|
||||
</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={order} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1.5">
|
||||
{orderedDefs.map((def) => {
|
||||
const on = visible[def.key] ?? true
|
||||
return (
|
||||
<SortableCardRow
|
||||
key={def.key}
|
||||
id={def.key}
|
||||
label={def.label}
|
||||
desc={def.desc}
|
||||
on={on}
|
||||
onToggle={() => toggle(def.key)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<div className="flex items-center justify-end pt-1">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-2 py-0.5 rounded-btn text-[10px] text-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 可拖拽的卡片行 ──
|
||||
function SortableCardRow({
|
||||
id, label, desc, on, onToggle,
|
||||
}: {
|
||||
id: CardKey
|
||||
label: string
|
||||
desc: string
|
||||
on: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center gap-2 rounded-card border px-3 py-2 transition-colors ${
|
||||
isDragging ? 'bg-elevated shadow-lg' : ''
|
||||
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30'}`}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors shrink-0"
|
||||
title="拖动排序"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{/* 显隐勾选 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
on ? 'bg-accent border-accent' : 'bg-base border-border'
|
||||
}`}
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
>
|
||||
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-foreground">{label}</div>
|
||||
<div className="text-[10px] text-muted leading-snug">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
type PullKey = 'pipeline_pull_a_share' | 'pipeline_pull_etf' | 'pipeline_pull_index'
|
||||
|
||||
interface ScopeItem {
|
||||
key: PullKey
|
||||
label: string
|
||||
desc: string
|
||||
defaultOn: boolean
|
||||
}
|
||||
|
||||
const ITEMS: ScopeItem[] = [
|
||||
{ key: 'pipeline_pull_a_share', label: 'A股', desc: '沪深京 A 股日K(约 5500 只)', defaultOn: true },
|
||||
{ key: 'pipeline_pull_index', label: '指数', desc: '主要市场指数(默认全量约 600 只)', defaultOn: true },
|
||||
{ key: 'pipeline_pull_etf', label: 'ETF', desc: '场内交易基金(约 1500 只,首次较慢)', defaultOn: false },
|
||||
]
|
||||
|
||||
export function PipelineScopeConfig() {
|
||||
const qc = useQueryClient()
|
||||
const prefs = useQuery({ queryKey: QK.preferences, queryFn: api.preferences })
|
||||
|
||||
const updateToggle = useMutation({
|
||||
mutationFn: (cfg: Partial<Record<PullKey, boolean>>) => api.updatePipelinePullTypes(cfg),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
qc.invalidateQueries({ queryKey: QK.dataStatus })
|
||||
},
|
||||
})
|
||||
|
||||
const getValue = (key: PullKey, def: boolean) => prefs.data?.[key] ?? def
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs text-secondary leading-relaxed">
|
||||
勾选盘后管道每次自动拉取的数据类型。仅影响后续同步,已存储的历史数据不受影响。
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{ITEMS.map((item) => {
|
||||
const locked = item.key === 'pipeline_pull_a_share'
|
||||
const on = locked || getValue(item.key, item.defaultOn)
|
||||
return (
|
||||
<div key={item.key}>
|
||||
<label
|
||||
className={`flex items-start gap-2.5 rounded-card border px-3 py-2.5 transition-colors ${
|
||||
locked ? 'cursor-default' : 'cursor-pointer'
|
||||
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30 hover:border-border/70'}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!locked) updateToggle.mutate({ [item.key]: !on } as never)
|
||||
}}
|
||||
disabled={locked || updateToggle.isPending}
|
||||
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
on ? 'bg-accent border-accent' : 'bg-base border-border'
|
||||
} ${locked ? 'opacity-80' : ''}`}
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
>
|
||||
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-foreground">{item.label}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted leading-snug mt-0.5">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{updateToggle.isPending && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />保存中…
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-muted leading-relaxed pt-1">
|
||||
数据通道基于免费接口,所有档位均可拉取。
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,105 @@
|
||||
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',
|
||||
etf_instruments: 'ETF 维表',
|
||||
etf_daily: 'ETF 日 K',
|
||||
etf_enriched: 'ETF 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,318 @@
|
||||
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 服务器(None 档/Free 档)即可获取,无需付费能力门控。
|
||||
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: '' },
|
||||
// ETF 复用日K批量能力(免费档 kline.daily.batch 即可),不显示档位徽章
|
||||
etf: { capKey: 'kline.daily.batch', 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,326 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, Search, Check, Clock, Zap, Settings2, AlertCircle, CheckCircle2, Calendar } from 'lucide-react'
|
||||
import { api, type ExtDataConfig } from '@/lib/api'
|
||||
import { toast } from '@/components/Toast'
|
||||
|
||||
export function ExtDataPullPanel({ config, onSaved }: {
|
||||
config: ExtDataConfig
|
||||
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 [runResult, setRunResult] = useState<{ rows: number; date: string } | null>(null)
|
||||
const [testResult, setTestResult] = useState<{ total_rows: number; preview: Record<string, unknown>[]; has_symbol: boolean } | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 解析 JSON 输入, 失败时设置 error 并返回 null
|
||||
const parseJson = (str: string, label: string): Record<string, string> | undefined | null => {
|
||||
if (!str.trim()) return undefined
|
||||
try { return JSON.parse(str) }
|
||||
catch { setError(`${label} 不是有效 JSON`); return null }
|
||||
}
|
||||
|
||||
// 构建保存 payload (复用当前编辑态), enabledOverride 用于开关自动保存
|
||||
const buildPayload = (enabledOverride?: boolean) => {
|
||||
const headers = parseJson(headerStr, 'Headers')
|
||||
if (headers === null) return null
|
||||
const field_map = parseJson(fieldMapStr, '字段映射')
|
||||
if (field_map === null) return null
|
||||
return {
|
||||
url, method, headers, body: body || undefined,
|
||||
response_path: responsePath, field_map,
|
||||
schedule_minutes: schedule, enabled: enabledOverride ?? enabled,
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = (silent = false) => {
|
||||
const payload = buildPayload()
|
||||
if (!payload) return
|
||||
setSaving(true); setError('')
|
||||
api.extDataPullConfig(config.id, payload)
|
||||
.then(() => {
|
||||
onSaved()
|
||||
if (!silent) toast('配置已保存', 'success')
|
||||
})
|
||||
.catch(e => setError(e.message || '保存失败'))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
const handleTest = () => {
|
||||
setTesting(true); setError(''); setTestResult(null)
|
||||
const payload = buildPayload()
|
||||
if (!payload) { setTesting(false); return }
|
||||
api.extDataPullConfig(config.id, payload)
|
||||
.then(() => api.extDataPullTest(config.id))
|
||||
.then(r => { setTestResult(r); onSaved() })
|
||||
.catch(e => setError(e.message || '测试失败'))
|
||||
.finally(() => setTesting(false))
|
||||
}
|
||||
|
||||
const handleRun = () => {
|
||||
setRunning(true); setError(''); setRunResult(null)
|
||||
api.extDataPullRun(config.id)
|
||||
.then(r => {
|
||||
setRunResult({ rows: r.rows, date: r.date })
|
||||
onSaved()
|
||||
toast(`拉取成功 · ${r.rows} 行`, 'success')
|
||||
})
|
||||
.catch(e => setError(e.message || '执行失败'))
|
||||
.finally(() => setRunning(false))
|
||||
}
|
||||
|
||||
// 开关 toggle: 自动保存全量配置 (切换 enabled), 后端 refresh 后立即首次拉取
|
||||
const [toggling, setToggling] = useState(false)
|
||||
const handleToggle = (next: boolean) => {
|
||||
if (toggling) return
|
||||
if (next && !url.trim()) {
|
||||
toast('请先填写拉取 URL', 'error')
|
||||
return
|
||||
}
|
||||
const payload = buildPayload(next)
|
||||
if (!payload) return
|
||||
setToggling(true); setError(''); setEnabled(next)
|
||||
api.extDataPullConfig(config.id, payload)
|
||||
.then(() => {
|
||||
onSaved()
|
||||
toast(next ? '定时拉取已启用 · 立即执行首次拉取' : '定时拉取已关闭', 'success')
|
||||
})
|
||||
.catch(e => {
|
||||
setEnabled(!next) // 回滚
|
||||
setError(e.message || '切换失败')
|
||||
})
|
||||
.finally(() => setToggling(false))
|
||||
}
|
||||
|
||||
// 格式化时间显示
|
||||
const fmtTime = (iso: string | null | undefined) => {
|
||||
if (!iso) return null
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return null
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd} ${hh}:${mi}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* ===== 分区 ①: 请求配置 ===== */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-secondary">
|
||||
<Settings2 className="h-3 w-3 text-muted" />
|
||||
<span>请求配置</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
<select
|
||||
value={method} onChange={e => setMethod(e.target.value)}
|
||||
className="shrink-0 rounded-btn border border-border bg-elevated px-2 py-1.5 text-[11px] text-foreground"
|
||||
>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
<input
|
||||
value={url} onChange={e => setUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/data"
|
||||
className="flex-1 min-w-0 rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[11px] font-mono text-foreground placeholder:text-muted/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-muted mb-1">Headers (JSON,可选)</div>
|
||||
<textarea
|
||||
value={headerStr} onChange={e => setHeaderStr(e.target.value)}
|
||||
placeholder='{"Authorization": "Bearer xxx"}'
|
||||
rows={2}
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{method === 'POST' && (
|
||||
<div>
|
||||
<div className="text-[10px] text-muted mb-1">请求体 (JSON,可选)</div>
|
||||
<textarea
|
||||
value={body} onChange={e => setBody(e.target.value)}
|
||||
placeholder='{"page": 1}'
|
||||
rows={2}
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted mb-1">响应数据路径</div>
|
||||
<input
|
||||
value={responsePath} onChange={e => setResponsePath(e.target.value)}
|
||||
placeholder="data.list"
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted mb-1">调度间隔 (分钟)</div>
|
||||
<input
|
||||
type="number" min={1} value={schedule} onChange={e => setSchedule(Number(e.target.value))}
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-muted mb-1">字段映射 (外部名 → 内部名,JSON,可选)</div>
|
||||
<textarea
|
||||
value={fieldMapStr} onChange={e => setFieldMapStr(e.target.value)}
|
||||
placeholder='{"code": "symbol", "val": "score"}'
|
||||
rows={2}
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 分区 ②: 定时拉取状态 ===== */}
|
||||
<div className="rounded-card border border-border/60 bg-elevated/30 p-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-secondary">
|
||||
<Clock className="h-3 w-3 text-muted" />
|
||||
<span>定时拉取</span>
|
||||
</div>
|
||||
{/* 自定义 Toggle 开关 */}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
disabled={toggling}
|
||||
onClick={() => handleToggle(!enabled)}
|
||||
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors duration-200 disabled:opacity-50 ${
|
||||
enabled ? 'bg-accent' : 'bg-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform duration-200 ${
|
||||
enabled ? 'translate-x-3.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 状态文案 */}
|
||||
<div className="text-[10px] leading-relaxed">
|
||||
{enabled ? (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-1 text-accent">
|
||||
<span className="h-1 w-1 rounded-full bg-accent animate-pulse" />
|
||||
<span>已启用 · 每 {schedule} 分钟</span>
|
||||
</div>
|
||||
{pull?.next_run && fmtTime(pull.next_run) && (
|
||||
<div className="flex items-center gap-1 text-muted">
|
||||
<Calendar className="h-2.5 w-2.5" />
|
||||
<span>下次:{fmtTime(pull.next_run)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted">未启用 · 仅手动执行</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 上次执行结果 */}
|
||||
{pull?.last_run && (
|
||||
<div className="flex items-start gap-1.5 pt-1.5 border-t border-border/40">
|
||||
{pull.last_status === 'success' ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500 shrink-0 mt-px" />
|
||||
) : (
|
||||
<AlertCircle className="h-3 w-3 text-danger shrink-0 mt-px" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-[10px] font-medium ${pull.last_status === 'success' ? 'text-emerald-500' : 'text-danger'}`}>
|
||||
{pull.last_message || (pull.last_status === 'success' ? '成功' : '失败')}
|
||||
</div>
|
||||
{fmtTime(pull.last_run) && (
|
||||
<div className="text-[9px] text-muted">{fmtTime(pull.last_run)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 分区 ③: 操作按钮 ===== */}
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !url}
|
||||
className="inline-flex items-center justify-center gap-1 px-2 py-2 rounded-btn border border-border bg-elevated text-xs text-foreground hover:bg-border/30 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running || !url}
|
||||
className="inline-flex items-center justify-center gap-1 px-2 py-2 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Zap className="h-3.5 w-3.5" />}
|
||||
立即执行
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={saving || !url}
|
||||
className="w-full inline-flex items-center justify-center gap-1 py-2 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ===== 结果展示 ===== */}
|
||||
{runResult && (
|
||||
<div className="rounded-card border border-emerald-500/30 bg-emerald-500/[0.06] p-2.5 flex items-center justify-between text-[10px]">
|
||||
<span className="text-emerald-500 font-medium flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />拉取成功
|
||||
</span>
|
||||
<span className="text-secondary">{runResult.rows} 行 · {runResult.date}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<div className="rounded-card border border-accent/30 bg-accent/[0.04] p-2.5 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-accent font-medium">测试成功</span>
|
||||
<span className="text-secondary">{testResult.total_rows} 行</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-[10px] text-danger text-center bg-danger/[0.06] rounded-btn py-1.5">
|
||||
{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,266 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
X, Sparkles, Loader2, AlertTriangle, Copy, Check, RefreshCw,
|
||||
Database, Settings2, Send, Wand2, Minimize2, History,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import {
|
||||
type ActiveTask, type HistoryReport,
|
||||
minimizeDialog, closeDialog, startAnalysis,
|
||||
} from '@/lib/aiReportStore'
|
||||
|
||||
interface Props {
|
||||
/** 当前展示的任务;活跃任务或历史报告 */
|
||||
task: ActiveTask | HistoryReport | null
|
||||
mode: 'active' | 'history' | null
|
||||
minimized: boolean
|
||||
}
|
||||
|
||||
type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
// 统一字段读取:活跃任务有 phase/createdAt,历史报告没有(按 done 处理)
|
||||
function getPhase(task: ActiveTask | HistoryReport | null): Phase {
|
||||
if (!task) return 'loading'
|
||||
if ('phase' in task) return task.phase
|
||||
return 'done' // 历史报告视为已完成
|
||||
}
|
||||
function getContent(task: ActiveTask | HistoryReport | null): string {
|
||||
return task?.content ?? ''
|
||||
}
|
||||
function getMeta(task: ActiveTask | HistoryReport | null) {
|
||||
if (!task) return null
|
||||
if ('meta' in task) return task.meta
|
||||
// 历史报告
|
||||
return { summary: task.summary, periods: task.periods }
|
||||
}
|
||||
|
||||
export function AiAnalysisDialog({ task, mode, minimized }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const focusInputRef = useRef<HTMLInputElement>(null)
|
||||
const [focus, setFocus] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const phase = getPhase(task)
|
||||
const content = getContent(task)
|
||||
const meta = getMeta(task)
|
||||
const isHistory = mode === 'history'
|
||||
const isWorking = phase === 'loading' || phase === 'streaming'
|
||||
const open = !!task && !minimized
|
||||
|
||||
// 流式时自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (open && phase === 'streaming' && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [content, phase, open])
|
||||
|
||||
// 切换任务时回填 focus
|
||||
useEffect(() => {
|
||||
setFocus(task && 'focus' in task ? task.focus : '')
|
||||
}, [task])
|
||||
|
||||
const handleStartNew = useCallback(async () => {
|
||||
if (!task) return
|
||||
const name = 'name' in task ? task.name : ''
|
||||
await startAnalysis(task.symbol, name, focus.trim())
|
||||
}, [task, focus])
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!content) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const error = task && 'error' in task ? task.error : ''
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
|
||||
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
|
||||
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* ===== 头部 ===== */}
|
||||
<div className="relative px-5 py-3.5 border-b border-border/50 bg-gradient-to-r from-purple-500/[0.06] via-fuchsia-500/[0.04] to-transparent">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 shrink-0">
|
||||
{isHistory
|
||||
? <History className="h-4.5 w-4.5 text-purple-300" />
|
||||
: <Sparkles className="h-4.5 w-4.5 text-purple-300" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground truncate">
|
||||
{isHistory ? '历史分析报告' : 'AI 财务分析'}
|
||||
</span>
|
||||
{task && <span className="text-xs text-secondary truncate">{task.name}</span>}
|
||||
{task && <span className="text-[10px] font-mono text-muted shrink-0">{task.symbol}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted">
|
||||
{meta?.summary ? (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Database className="h-2.5 w-2.5 shrink-0" />
|
||||
<span className="truncate">{meta.summary}</span>
|
||||
</span>
|
||||
) : isWorking ? <span>正在准备数据…</span> : null}
|
||||
{phase === 'streaming' && (
|
||||
<span className="flex items-center gap-1 text-purple-300 shrink-0">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-purple-400 animate-pulse" />生成中
|
||||
</span>
|
||||
)}
|
||||
{isHistory && task && 'created_at' in task && (
|
||||
<span className="shrink-0">{fmtRelative(task.created_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 右侧操作按钮 */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* 复制:仅在内容就绪且非生成中显示 */}
|
||||
{content && !isWorking && (
|
||||
<button onClick={handleCopy} title="复制全文"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
{/* 生成中:仅最小化(后台继续生成),无关闭按钮 */}
|
||||
{!isHistory && isWorking && (
|
||||
<button onClick={minimizeDialog} title="最小化为气泡,后台继续生成"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* 完成态/历史报告:显示关闭按钮 */}
|
||||
{(!isWorking || isHistory) && (
|
||||
<button onClick={closeDialog} title="关闭"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 内容区 ===== */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 min-h-[280px]">
|
||||
{/* 加载态 */}
|
||||
{phase === 'loading' && !content && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div className="relative">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 flex items-center justify-center">
|
||||
<Sparkles className="h-4.5 w-4.5 text-purple-300 animate-pulse" />
|
||||
</div>
|
||||
<Loader2 className="absolute -inset-1 h-12 w-12 text-purple-400/40 animate-spin" style={{ animationDuration: '3s' }} />
|
||||
</div>
|
||||
<div className="text-xs text-secondary">AI 正在分析财务数据…</div>
|
||||
<div className="text-[10px] text-muted">读取利润表 / 资负表 / 现金流 / 核心指标,生成专业报告</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误态 */}
|
||||
{phase === 'error' && (
|
||||
<div className="flex flex-col items-center justify-center py-14 gap-3">
|
||||
<div className="h-11 w-11 rounded-full bg-danger/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">分析失败</div>
|
||||
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
|
||||
{error.includes('AI') && (
|
||||
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
|
||||
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
|
||||
<Settings2 className="h-3.5 w-3.5" /> 去配置 AI
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleStartNew}
|
||||
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-purple-500/15 border border-purple-400/30 text-xs text-purple-300 hover:bg-purple-500/20 transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" /> 重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 报告内容 */}
|
||||
{(content || phase === 'streaming') && (
|
||||
<div className="relative">
|
||||
<MarkdownRenderer content={content} />
|
||||
{phase === 'streaming' && (
|
||||
<span className="inline-block w-1.5 h-3.5 bg-purple-400 ml-0.5 align-middle animate-pulse rounded-sm" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 底部:自定义关注点输入 ===== */}
|
||||
<div className="border-t border-border/50 bg-surface/60 px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted shrink-0">
|
||||
<Wand2 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">关注重点</span>
|
||||
</div>
|
||||
<input
|
||||
ref={focusInputRef}
|
||||
type="text"
|
||||
value={focus}
|
||||
onChange={e => setFocus(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && (phase === 'done' || phase === 'error' || isHistory)) handleStartNew() }}
|
||||
disabled={isWorking}
|
||||
placeholder={isHistory ? '修改关注重点,回车重新生成' : (phase === 'done' ? '如:重点看债务风险…回车重新分析' : '可留空,留空则全面分析')}
|
||||
className={cn(
|
||||
'flex-1 h-8 px-3 rounded-lg bg-base ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-400/30 transition-shadow disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
{isHistory ? (
|
||||
<button
|
||||
onClick={handleStartNew}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 text-xs font-medium text-purple-300 hover:from-purple-500/30 hover:to-fuchsia-500/20 transition-all shrink-0"
|
||||
title="以此关注点重新生成新报告"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />重新生成
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartNew}
|
||||
disabled={isWorking}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 text-xs font-medium text-purple-300 hover:from-purple-500/30 hover:to-fuchsia-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0"
|
||||
title={focus.trim() ? '按关注重点重新分析' : '重新分析'}
|
||||
>
|
||||
{isWorking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : phase === 'done' ? <RefreshCw className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
|
||||
{phase === 'done' ? '重新分析' : '分析'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] text-muted/50 leading-relaxed">
|
||||
{isHistory
|
||||
? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
|
||||
: '报告由项目已配置的 AI 模型基于本地财务数据生成;可在输入框追加关注点后重新生成。报告仅供参考,不构成投资建议。'}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 小工具 =====
|
||||
function fmtRelative(iso: string): string {
|
||||
try {
|
||||
const t = new Date(iso).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
} catch { return '' }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useDialogTask, useDialogState } from '@/lib/aiReportStore'
|
||||
import { AiAnalysisDialog } from './AiAnalysisDialog'
|
||||
|
||||
/**
|
||||
* AI 分析对话框宿主 —— 单点挂载在 Layout。
|
||||
*
|
||||
* 从 store 读取当前对话框状态(任务 + 最小化),把 AiAnalysisDialog 作为纯视图渲染。
|
||||
* 一次挂载,全局生效:任意页面发起的分析都会显示在这个对话框里。
|
||||
*/
|
||||
export function AiAnalysisHost() {
|
||||
const { task, mode } = useDialogTask()
|
||||
const { minimized } = useDialogState()
|
||||
return <AiAnalysisDialog task={task} mode={mode} minimized={minimized} />
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Loader2, Check, AlertCircle } from 'lucide-react'
|
||||
import { useActiveTasks, restoreDialog } from '@/lib/aiReportStore'
|
||||
import type { ActiveTask } from '@/lib/aiReportStore'
|
||||
|
||||
/**
|
||||
* AI 分析任务全局气泡容器 —— 玻璃拟态卡片,挂在网页右侧。
|
||||
*
|
||||
* 拖拽丝滑的关键(60fps):
|
||||
* - 位置用 transform: translate3d 存储(走 GPU 合成层,不触发 layout/paint)
|
||||
* - 拖动期间直接操作 DOM.style.transform,完全绕开 React setState 重渲染
|
||||
* - 拖动结束才同步一次 state + 持久化 localStorage
|
||||
* - 拖动时给容器加 .dragging 类,禁用所有 transition,消除回弹延迟
|
||||
*
|
||||
* 视觉:
|
||||
* - 玻璃拟态(frosted glass):半透明 + backdrop-blur + 细边框 + 内发光
|
||||
* - 固定宽度,内容居中,多任务竖向堆叠
|
||||
* - 生成中:柔和呼吸光环(非刺眼 ping)
|
||||
* - hover:展开操作区,带平滑过渡
|
||||
*/
|
||||
|
||||
const BUBBLE_W = 148 // 卡片固定宽度(紧凑单行版)
|
||||
const EDGE_MARGIN = 12 // 距视口边缘最小间距
|
||||
|
||||
export function AiReportBubble() {
|
||||
const activeTasks = useActiveTasks()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// pos 只在拖拽结束时更新一次(用于初始化/持久化),拖拽过程不触发它
|
||||
const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos())
|
||||
|
||||
// ===== 拖拽(纯 DOM 操作,60fps) =====
|
||||
const draggingRef = useRef(false)
|
||||
const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 }) // 鼠标起点 + 元素起点
|
||||
const movedRef = useRef(false) // 本次是否真的移动了(区分点击)
|
||||
// 记录 pointerdown 时命中的卡片回调(松手时若未拖动则触发它 = 点击)
|
||||
const clickTargetRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const applyTransform = useCallback((x: number, y: number) => {
|
||||
const el = containerRef.current
|
||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)`
|
||||
}, [])
|
||||
|
||||
const clamp = useCallback((x: number, y: number) => {
|
||||
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
|
||||
const maxY = window.innerHeight - 80
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
draggingRef.current = true
|
||||
movedRef.current = false
|
||||
dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y }
|
||||
const el = containerRef.current
|
||||
if (el) el.classList.add('dragging')
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}, [pos.x, pos.y])
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return
|
||||
const dx = e.clientX - dragData.current.mx
|
||||
const dy = e.clientY - dragData.current.my
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
|
||||
const nx = dragData.current.ox + dx
|
||||
const ny = dragData.current.oy + dy
|
||||
const c = clamp(nx, ny)
|
||||
applyTransform(c.x, c.y) // ← 直接改 DOM,不走 React,丝滑
|
||||
}, [clamp, applyTransform])
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
const el = containerRef.current
|
||||
if (el) el.classList.remove('dragging')
|
||||
if (movedRef.current) {
|
||||
// 拖动结束 → 持久化位置
|
||||
setPos(prev => {
|
||||
const transform = el?.style.transform ?? ''
|
||||
const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/)
|
||||
const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev
|
||||
savePos(finalPos)
|
||||
return finalPos
|
||||
})
|
||||
} else {
|
||||
// 未移动 → 视为点击,触发卡片回调
|
||||
const fn = clickTargetRef.current
|
||||
clickTargetRef.current = null
|
||||
fn?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 窗口尺寸变化时确保不越界
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setPos(prev => {
|
||||
const c = clamp(prev.x, prev.y)
|
||||
if (c.x !== prev.x || c.y !== prev.y) {
|
||||
applyTransform(c.x, c.y)
|
||||
return c
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [clamp, applyTransform])
|
||||
|
||||
// 初始化 transform(pos 变化时同步,如 resize / 首次挂载)
|
||||
useEffect(() => {
|
||||
applyTransform(pos.x, pos.y)
|
||||
}, [pos.x, pos.y, applyTransform])
|
||||
|
||||
if (activeTasks.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="ai-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
width: `${BUBBLE_W}px`,
|
||||
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
|
||||
touchAction: 'none',
|
||||
// 拖动时禁用过渡(通过 .dragging 类控制);静止时用 transition 让 resize/吸附有动画
|
||||
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{activeTasks.map((task, i) => (
|
||||
<BubbleItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isLast={i === activeTasks.length - 1}
|
||||
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 内联样式:拖动时禁用过渡,确保 1:1 跟手 */}
|
||||
<style>{`
|
||||
.ai-bubble-root.dragging { transition: none !important; }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 单个胶囊卡片(紧凑玻璃拟态) =====
|
||||
function BubbleItem({ task, isLast, onPointerDown }: {
|
||||
task: ActiveTask
|
||||
isLast: boolean
|
||||
onPointerDown: () => void
|
||||
}) {
|
||||
const isWorking = task.phase === 'loading' || task.phase === 'streaming'
|
||||
const isError = task.phase === 'error'
|
||||
|
||||
// 状态配色
|
||||
const accent = isWorking
|
||||
? 'from-purple-500/25 to-fuchsia-500/20 text-purple-300 border-purple-300/40 shadow-[0_6px_24px_-10px_rgba(168,85,247,0.5)]'
|
||||
: isError
|
||||
? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]'
|
||||
: 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -8 }}
|
||||
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
|
||||
className={isLast ? '' : 'mb-1.5'}
|
||||
>
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={isWorking ? '生成中,点击恢复对话框' : isError ? '分析失败,点击重试' : '点击查看报告'}
|
||||
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
|
||||
>
|
||||
{/* 生成中:顶部进度流光 */}
|
||||
{isWorking && (
|
||||
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
|
||||
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-purple-200 to-transparent animate-bubble-progress" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态图标 */}
|
||||
<span className="flex h-4 w-4 items-center justify-center shrink-0">
|
||||
{isWorking ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : isError ? (
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 标的名(单行) */}
|
||||
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
|
||||
{task.name || task.symbol}
|
||||
</span>
|
||||
|
||||
{/* 状态后缀 */}
|
||||
<span className="shrink-0 text-[9px] leading-none">
|
||||
{isWorking ? (
|
||||
<span className="text-purple-300/80">分析中</span>
|
||||
) : isError ? (
|
||||
<span className="text-red-300/80">失败</span>
|
||||
) : (
|
||||
<span className="text-emerald-300/80">点击查看</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 内联关键帧:进度条流动 */}
|
||||
<style>{`
|
||||
@keyframes bubble-progress {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
.animate-bubble-progress { animation: bubble-progress 1.6s ease-in-out infinite; }
|
||||
`}</style>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 位置持久化 =====
|
||||
const POS_KEY = 'ai_bubble_pos'
|
||||
function loadPos(): { x: number; y: number } {
|
||||
// 默认:右下角(距右边缘 EDGE_MARGIN,距底部留出空间避开右下角元素)
|
||||
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
|
||||
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 200)
|
||||
try {
|
||||
const v = localStorage.getItem(POS_KEY)
|
||||
if (v) {
|
||||
const p = JSON.parse(v)
|
||||
if (typeof p.x === 'number' && typeof p.y === 'number') {
|
||||
// 钳制到当前视口(防止保存的位置在缩小后的窗口外)
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { x: defaultX, y: defaultY }
|
||||
}
|
||||
function savePos(p: { x: number; y: number }) {
|
||||
try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 轻量 Markdown 渲染器 — 零依赖,专为 AI 财务分析报告设计。
|
||||
*
|
||||
* 支持的语法(AI 财务分析提示词约束的子集,足够用):
|
||||
* - 标题 # ## ### ####
|
||||
* - 加粗 **text**
|
||||
* - 行内代码 `code`
|
||||
* - 无序列表 - / *
|
||||
* - 有序列表 1.
|
||||
* - 表格 | a | b |
|
||||
* - 引用 >
|
||||
* - 分隔线 --- / ***
|
||||
* - 段落
|
||||
*
|
||||
* 不追求完整 GFM,只覆盖 AI 报告会产出的结构。
|
||||
*/
|
||||
|
||||
// ===== 行内格式:加粗 / 行内代码 / 星号评级 =====
|
||||
|
||||
function renderInline(text: string, keyBase: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = []
|
||||
// 正则:匹配 **加粗** 或 `代码` 或 ★ 评级
|
||||
const re = /(\*\*([^*]+)\*\*)|(`([^`]+)`)/g
|
||||
let last = 0
|
||||
let m: RegExpExecArray | null
|
||||
let i = 0
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) nodes.push(<Fragment key={`${keyBase}-t-${i}`}>{text.slice(last, m.index)}</Fragment>)
|
||||
if (m[1]) {
|
||||
// 加粗
|
||||
nodes.push(<strong key={`${keyBase}-b-${i}`} className="font-semibold text-foreground">{m[2]}</strong>)
|
||||
} else if (m[3]) {
|
||||
// 行内代码
|
||||
nodes.push(
|
||||
<code key={`${keyBase}-c-${i}`} className="px-1 py-0.5 rounded bg-elevated text-[0.85em] font-mono text-accent">
|
||||
{m[4]}
|
||||
</code>,
|
||||
)
|
||||
}
|
||||
last = m.index + m[0].length
|
||||
i++
|
||||
}
|
||||
if (last < text.length) nodes.push(<Fragment key={`${keyBase}-t-end`}>{text.slice(last)}</Fragment>)
|
||||
return nodes
|
||||
}
|
||||
|
||||
// ===== 表格解析 =====
|
||||
|
||||
function parseTable(lines: string[], start: number): { rows: string[][]; consumed: number } | null {
|
||||
// 找到连续的表格行(以 | 开头)
|
||||
const tableLines: string[] = []
|
||||
let idx = start
|
||||
while (idx < lines.length && lines[idx].trim().startsWith('|')) {
|
||||
tableLines.push(lines[idx].trim())
|
||||
idx++
|
||||
}
|
||||
if (tableLines.length < 2) return null
|
||||
// 第二行必须是分隔行 |---|---|
|
||||
if (!/^|[\s-:|]+$/.test(tableLines[1]) && !tableLines[1].split('|').every(c => /^[\s-:]*$/.test(c))) {
|
||||
return null
|
||||
}
|
||||
const parseRow = (line: string) =>
|
||||
line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
|
||||
const header = parseRow(tableLines[0])
|
||||
const body = tableLines.slice(2).map(parseRow)
|
||||
return { rows: [header, ...body], consumed: tableLines.length }
|
||||
}
|
||||
|
||||
// ===== 主渲染 =====
|
||||
|
||||
export function MarkdownRenderer({ content }: { content: string }) {
|
||||
const lines = content.replace(/\r\n/g, '\n').split('\n')
|
||||
const blocks: ReactNode[] = []
|
||||
let i = 0
|
||||
let key = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
const trimmed = line.trim()
|
||||
|
||||
// 空行
|
||||
if (!trimmed) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 分隔线
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
||||
blocks.push(<hr key={key++} className="my-6 border-border" />)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 标题
|
||||
const hMatch = trimmed.match(/^(#{1,4})\s+(.+)$/)
|
||||
if (hMatch) {
|
||||
const level = hMatch[1].length
|
||||
const text = hMatch[2]
|
||||
const sizeCls = level === 1 ? 'text-base' : level === 2 ? 'text-sm' : 'text-xs'
|
||||
const mtCls = level <= 2 ? 'mt-6' : 'mt-5'
|
||||
blocks.push(
|
||||
<div key={key++} className={`${sizeCls} ${mtCls} mb-3 font-semibold text-foreground flex items-center gap-1.5`}>
|
||||
{renderInline(text, `h-${key}`)}
|
||||
</div>,
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 引用
|
||||
if (trimmed.startsWith('>')) {
|
||||
const quoteLines: string[] = []
|
||||
while (i < lines.length && lines[i].trim().startsWith('>')) {
|
||||
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''))
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<blockquote key={key++} className="my-4 pl-3 border-l-2 border-amber-400/40 bg-amber-400/[0.04] py-1.5 pr-2 rounded-r text-xs text-secondary">
|
||||
{renderInline(quoteLines.join(' '), `q-${key}`)}
|
||||
</blockquote>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 表格
|
||||
if (trimmed.startsWith('|')) {
|
||||
const table = parseTable(lines, i)
|
||||
if (table) {
|
||||
const [header, ...body] = table.rows
|
||||
const ncol = header.length
|
||||
blocks.push(
|
||||
<div key={key++} className="my-5 overflow-hidden rounded-btn border border-border/30">
|
||||
<table className="w-full text-xs border-collapse table-fixed">
|
||||
<colgroup>
|
||||
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */}
|
||||
<col className="w-auto" />
|
||||
{Array.from({ length: ncol - 1 }).map((_, ci) => (
|
||||
<col key={ci} className={ci === ncol - 2 ? 'w-1/2' : 'w-auto'} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="bg-elevated/50">
|
||||
{header.map((cell, ci) => (
|
||||
<th key={ci} className="px-2.5 py-1.5 text-left font-medium text-foreground border-b border-border/40 whitespace-nowrap">
|
||||
{renderInline(cell, `th-${key}-${ci}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-border/20 last:border-0 hover:bg-elevated/20">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-2.5 py-1.5 text-foreground align-top break-words">
|
||||
{renderInline(cell, `td-${key}-${ri}-${ci}`)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>,
|
||||
)
|
||||
i += table.consumed
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 无序列表
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items: string[] = []
|
||||
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*[-*]\s+/, ''))
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<ul key={key++} className="my-4 space-y-2">
|
||||
{items.map((item, ii) => (
|
||||
<li key={ii} className="flex items-start gap-2 text-sm text-foreground leading-relaxed">
|
||||
<span className="mt-[7px] h-1 w-1 rounded-full bg-accent/60 shrink-0" />
|
||||
<span>{renderInline(item, `li-${key}-${ii}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 有序列表
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = []
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''))
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<ol key={key++} className="my-4 space-y-2">
|
||||
{items.map((item, ii) => (
|
||||
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
|
||||
<span className="mt-0.5 h-4 w-4 rounded-full bg-accent/10 text-accent text-[10px] font-mono flex items-center justify-center shrink-0">
|
||||
{ii + 1}
|
||||
</span>
|
||||
<span className="flex-1 text-foreground">{renderInline(item, `ol-${key}-${ii}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 普通段落
|
||||
blocks.push(
|
||||
<p key={key++} className="my-3 text-sm text-foreground leading-relaxed">
|
||||
{renderInline(trimmed, `p-${key}`)}
|
||||
</p>,
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
// 注意:外层不加 space-y-*,否则会覆盖各块自己的 margin-top 导致间距失效。
|
||||
// 让各块的 my-* 自然叠加(margin collapse),间距更可控。
|
||||
return <div>{blocks}</div>
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect } from 'react'
|
||||
import { History, Trash2, FileText, Clock, Sparkles, Loader2 } from 'lucide-react'
|
||||
import { useHistoryReports, openHistoryReport, deleteReport, loadHistory } from '@/lib/aiReportStore'
|
||||
import { useActiveTasks } from '@/lib/aiReportStore'
|
||||
|
||||
/**
|
||||
* AI 财务分析历史报告面板 —— 显示在财务页底部。
|
||||
*
|
||||
* - 列出最近 20 条报告(后端裁剪)
|
||||
* - 点击查看 → 打开到对话框(历史模式)
|
||||
* - 显示正在生成中的对应标的(若该标的有活跃任务,标注)
|
||||
* - 支持删除单条
|
||||
*/
|
||||
export function ReportHistoryPanel() {
|
||||
const { reports, loaded } = useHistoryReports()
|
||||
const activeTasks = useActiveTasks()
|
||||
|
||||
// 首次挂载拉取一次
|
||||
useEffect(() => { loadHistory() }, [])
|
||||
|
||||
// 活跃任务的 symbol 集合(用于在历史列表里标注"生成中")
|
||||
const activeSymbols = new Set(activeTasks.map(t => t.symbol))
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="rounded-card border border-border/40 bg-surface px-4 py-6 text-center">
|
||||
<Loader2 />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<div className="rounded-card border border-dashed border-border/50 bg-surface/50 px-6 py-8 text-center">
|
||||
<History className="mx-auto h-6 w-6 text-muted/40" />
|
||||
<div className="mt-2 text-xs text-muted">暂无历史分析报告</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted/60">选择个股后点击「AI 财务分析」生成,报告会自动保存在此</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface overflow-hidden">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border/50 bg-elevated/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-3.5 w-3.5 text-secondary" />
|
||||
<span className="text-xs font-medium text-foreground">历史分析报告</span>
|
||||
<span className="text-[10px] text-muted">{reports.length}/20</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted/60">点击查看 · 报告最多保留 20 条</span>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
|
||||
{reports.map(r => {
|
||||
const isGenerating = activeSymbols.has(r.symbol)
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className="group flex items-center gap-3 px-4 py-2.5 hover:bg-elevated/30 transition-colors cursor-pointer"
|
||||
onClick={() => openHistoryReport(r.id)}
|
||||
>
|
||||
{/* 图标 */}
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg shrink-0 ${
|
||||
isGenerating
|
||||
? 'bg-purple-400/10 text-purple-300'
|
||||
: 'bg-elevated text-secondary group-hover:text-accent'
|
||||
}`}>
|
||||
{isGenerating
|
||||
? <Sparkles className="h-3.5 w-3.5 animate-pulse" />
|
||||
: <FileText className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* 主信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-foreground truncate">{r.name || r.symbol}</span>
|
||||
<span className="text-[10px] font-mono text-muted shrink-0">{r.symbol}</span>
|
||||
{r.focus && (
|
||||
<span className="hidden sm:inline-block px-1.5 py-px rounded bg-purple-400/10 text-purple-300 text-[9px] shrink-0">
|
||||
{r.focus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 摘要 */}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] text-muted/70 flex items-center gap-1">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
{fmtRelative(r.created_at)}
|
||||
</span>
|
||||
{r.summary && (
|
||||
<span className="text-[10px] text-muted/50 truncate">{r.summary}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); deleteReport(r.id) }}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-danger/10 text-muted hover:text-danger transition-all shrink-0"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 小工具 =====
|
||||
function fmtRelative(iso: string): string {
|
||||
try {
|
||||
const t = new Date(iso).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
|
||||
return new Date(iso).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { CalendarDays, TrendingUp, FileText, Wallet, Activity, Sparkles, AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
useFinancialMetrics,
|
||||
useFinancialIncome,
|
||||
useFinancialBalanceSheet,
|
||||
useFinancialCashFlow,
|
||||
} from '@/lib/useFinancials'
|
||||
import { fmtPrice, fmtBigNum, fmtDate } from '@/lib/format'
|
||||
import { Skeleton } from '@/components/data/Skeleton'
|
||||
import { startAnalysis, findLatestHistoryReport, openHistoryReport } from '@/lib/aiReportStore'
|
||||
import { toast } from '@/components/Toast'
|
||||
|
||||
interface Props {
|
||||
symbol: string
|
||||
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 [checking, setChecking] = useState(false)
|
||||
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
|
||||
|
||||
const handleAiClick = async () => {
|
||||
if (checking) return
|
||||
setChecking(true)
|
||||
try {
|
||||
const latest = await findLatestHistoryReport(symbol)
|
||||
if (latest) {
|
||||
// 有历史报告 → 弹二次确认
|
||||
setConfirmReport({ id: latest.id, created_at: latest.created_at, focus: latest.focus })
|
||||
} else {
|
||||
// 无历史 → 直接分析
|
||||
await doAnalysis()
|
||||
}
|
||||
} catch {
|
||||
// 查询失败不阻塞,直接分析
|
||||
await doAnalysis()
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doAnalysis = async () => {
|
||||
const r = await startAnalysis(symbol, name)
|
||||
if (r.error) toast(r.error, 'error')
|
||||
}
|
||||
|
||||
const metrics = useFinancialMetrics(symbol)
|
||||
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="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">
|
||||
<button
|
||||
onClick={handleAiClick}
|
||||
disabled={checking}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-btn text-[11px] font-medium border border-purple-400/30 bg-purple-400/10 text-purple-300 hover:bg-purple-400/20 hover:border-purple-400/40 transition-all shrink-0 disabled:opacity-50"
|
||||
title="AI 财务分析"
|
||||
>
|
||||
{checking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
|
||||
AI 财务分析
|
||||
</button>
|
||||
{latestPeriod && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-secondary">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
<span>报告期 <span className="font-mono">{latestPeriod}</span></span>
|
||||
{latestAnnounce && (
|
||||
<span className="text-muted">· 披露 {fmtDate(latestAnnounce)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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 分析二次确认:已有该标的历史报告 */}
|
||||
<AnimatePresence>
|
||||
{confirmReport && (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setConfirmReport(null)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative w-[90vw] max-w-[400px] rounded-card border border-border bg-base shadow-2xl p-6"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 h-10 w-10 rounded-full bg-purple-400/12 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-purple-300" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1.5">该个股已有分析报告</h3>
|
||||
<p className="text-xs text-secondary leading-relaxed">
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
<span className="font-mono text-muted"> {symbol}</span> 在
|
||||
<span className="text-purple-300 font-medium"> {fmtReportTime(confirmReport.created_at)} </span>
|
||||
已生成过 AI 财务分析报告。
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-muted">
|
||||
您可以查看历史报告,或基于最新数据重新生成一份。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-5">
|
||||
<button
|
||||
onClick={() => setConfirmReport(null)}
|
||||
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-xs transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirmReport) openHistoryReport(confirmReport.id); setConfirmReport(null) }}
|
||||
className="px-3 py-1.5 rounded-btn border border-border text-secondary hover:text-foreground text-xs font-medium transition-colors"
|
||||
>
|
||||
查看历史报告
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { doAnalysis(); setConfirmReport(null) }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-purple-500/80 to-fuchsia-500/80 text-white text-xs font-medium hover:from-purple-500 hover:to-fuchsia-500 transition-all"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
重新分析
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 历史报告时间友好显示
|
||||
function fmtReportTime(iso: string): string {
|
||||
try {
|
||||
const t = new Date(iso).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
|
||||
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
@@ -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,444 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Save, X, Plus, Search } from 'lucide-react'
|
||||
import { api, genRuleId, type MonitorRule, type MonitorCondition } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { SignalPicker } from '@/components/screener/SignalPicker'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
|
||||
interface Props {
|
||||
/** 编辑现有规则;null=新建 */
|
||||
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 { data: prefs } = usePreferences()
|
||||
const feishuConfigured = !!(prefs?.feishu_webhook_url)
|
||||
const [editing] = useState(!!rule)
|
||||
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
|
||||
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
|
||||
const [draft, setDraft] = useState<MonitorRule>(
|
||||
rule
|
||||
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
|
||||
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) },
|
||||
)
|
||||
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' }
|
||||
// simple 模式(个股弹窗)默认现价; 完整模式默认 RSI 超卖
|
||||
: { field: simple ? 'close' : 'rsi_14', op: '<', value: simple ? 0 : 30 }],
|
||||
}))
|
||||
const removeCond = (idx: number) =>
|
||||
setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
|
||||
|
||||
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>
|
||||
|
||||
{/* 价位条件 (阈值) — 与信号共存, 可选添加 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted">价位条件 (可选)</span>
|
||||
<button onClick={() => addCond('threshold')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
|
||||
<Plus className="h-3 w-3" />添加价位
|
||||
</button>
|
||||
</div>
|
||||
{thresholdConds.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{thresholdConds.map((c, i) => {
|
||||
const realIdx = draft.conditions.indexOf(c)
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'}</span>
|
||||
<select value={c.field} onChange={e => updateCond(realIdx, { field: e.target.value })} className="flex-1 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
|
||||
{thresholdFields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
|
||||
</select>
|
||||
<select value={c.op} onChange={e => updateCond(realIdx, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
|
||||
{operators.map(op => <option key={op} value={op}>{op}</option>)}
|
||||
</select>
|
||||
<input type="number" value={c.value ?? 0} onChange={e => updateCond(realIdx, { value: parseFloat(e.target.value) })} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
|
||||
<button onClick={() => removeCond(realIdx)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">备注 (可选)</span>
|
||||
<input value={draft.message} onChange={e => setDraft(d => ({ ...d, message: e.target.value }))} placeholder="给这条监控加个备注" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
|
||||
</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 推送 — 飞书可用, QMT/ptrade 待定 */}
|
||||
<div className="rounded-btn border border-border/40 bg-base/40 p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-foreground">Webhook 推送</span>
|
||||
<span className="text-[9px] text-muted">触发时推送告警到外部</span>
|
||||
</div>
|
||||
|
||||
{/* 渠道列表 */}
|
||||
<div className="space-y-1.5">
|
||||
{/* 飞书 (可用) */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!draft.webhook_enabled}
|
||||
onChange={e => setDraft(d => ({ ...d, webhook_enabled: e.target.checked }))}
|
||||
className="h-3 w-3 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] text-foreground">飞书</span>
|
||||
<span className="text-[9px] text-muted">群机器人</span>
|
||||
{draft.webhook_enabled && (
|
||||
<span className={`ml-auto text-[9px] ${feishuConfigured ? 'text-emerald-500' : 'text-warning'}`}>
|
||||
{feishuConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* QMT (待定) */}
|
||||
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
|
||||
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
|
||||
<span className="text-[11px] text-secondary">QMT</span>
|
||||
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted">待定</span>
|
||||
</label>
|
||||
|
||||
{/* ptrade (待定) */}
|
||||
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
|
||||
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
|
||||
<span className="text-[11px] text-secondary">ptrade</span>
|
||||
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted">待定</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 飞书勾选但全局未配置 → 提示前往设置 */}
|
||||
{draft.webhook_enabled && !feishuConfigured && (
|
||||
<p className="text-[10px] leading-relaxed text-warning/80">
|
||||
飞书 Webhook 地址尚未配置,
|
||||
<Link to="/settings?tab=monitoring" className="text-accent hover:text-accent/80">前往设置页配置 →</Link>
|
||||
</p>
|
||||
)}
|
||||
{draft.webhook_enabled && feishuConfigured && (
|
||||
<p className="text-[10px] leading-relaxed text-muted">
|
||||
命中本规则时,告警将推送到设置页配置的飞书群。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
|
||||
|
||||
<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,547 @@
|
||||
import { useEffect, useRef, useMemo, useState } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts, EChartsOption } from 'echarts'
|
||||
import type { KlineRow, LevelSeries } from '@/lib/api'
|
||||
|
||||
/**
|
||||
* 个股分析专用日 K 图表。
|
||||
*
|
||||
* 与 StockDailyKChart/EChartsCandlestick 刻意不复用:
|
||||
* - 那套图表面向「行情浏览」,强调全套指标副图(MA/MACD/KDJ/BOLL)、涨停标记等;
|
||||
* - 本图表面向「分析决策」,核心是【关键价位】(压力/支撑/密集区/枢轴/前高前低),
|
||||
* 通过开关按钮控制各价位组的显隐,布局更简洁(主图 + 成交量即可)。
|
||||
*
|
||||
* 预留接口(类型已定义,渲染逻辑留 hook,后续实现):
|
||||
* - markers: 日期标记点(新闻/暴雷/利好 → markPoint)
|
||||
* - ranges: 区间高亮(事件区间 → markArea)
|
||||
* - onDateClick: 点击日期回调(后续接消息面时间轴)
|
||||
* - 指标副图: 后续如需 MACD/KDJ,按 SUB_CHARTS 模式扩展
|
||||
*/
|
||||
|
||||
// ===== 配色(与主图一致的红涨绿跌,深色背景) =====
|
||||
const THEME = {
|
||||
bull: '#C74040',
|
||||
bear: '#2D9B65',
|
||||
text: '#A1A1AA',
|
||||
grid: 'rgba(255,255,255,0.04)',
|
||||
volUp: 'rgba(240,68,56,0.5)',
|
||||
volDown: 'rgba(18,183,106,0.5)',
|
||||
}
|
||||
|
||||
// ===== 价位类型(与后端 levels.py 的 LEVEL_TYPES 对齐) =====
|
||||
export type LevelType = 'sr' | 'pivot' | 'extreme' | 'boll' | 'keltner_s' | 'keltner_m' | 'keltner_l' | 'atr_stop' | 'gap' | 'fib' | 'round'
|
||||
|
||||
export interface PriceLevel {
|
||||
value: number
|
||||
label: string
|
||||
type: LevelType
|
||||
side: 'resistance' | 'support' | 'neutral'
|
||||
strength?: 'strong' | 'medium' | 'weak'
|
||||
/** 档位(仅 pivot 有):0=P, 1=R1/S1, 2=R2/S2, 3=R3/S3 */
|
||||
rank?: number
|
||||
}
|
||||
|
||||
/** 价位组开关配置:label = 按钮文案,color = markLine 颜色 */
|
||||
export const LEVEL_GROUPS: { key: LevelType; label: string; color: string }[] = [
|
||||
{ key: 'sr', label: '压力支撑', color: '#F97316' }, // 橙(成交密集区,价量驱动)
|
||||
{ key: 'pivot', label: '枢轴点', color: '#8B5CF6' }, // 紫
|
||||
{ key: 'extreme', label: '前高前低', color: '#EAB308' }, // 黄
|
||||
{ key: 'boll', label: '布林带', color: '#F97316' }, // 橙(MA20±2σ 曲线)
|
||||
{ key: 'keltner_s',label: 'Keltner短期', color: '#06B6D4' }, // 青(MA20±2ATR 曲线)
|
||||
{ key: 'keltner_m',label: 'Keltner中期', color: '#22D3EE' }, // 浅青(MA60±2.5ATR 曲线)
|
||||
{ key: 'keltner_l',label: 'Keltner长期', color: '#67E8F9' }, // 更浅青(MA120±3ATR 曲线)
|
||||
{ key: 'atr_stop', label: 'ATR止损', color: '#EF4444' }, // 红(警示)
|
||||
{ key: 'gap', label: '缺口位', color: '#EC4899' }, // 粉
|
||||
{ key: 'fib', label: '斐波那契', color: '#F59E0B' }, // 金
|
||||
{ key: 'round', label: '整数关口', color: '#71717A' }, // 灰(心理位,弱视觉)
|
||||
]
|
||||
|
||||
// 通道曲线元数据(单一数据源):供 buildOption 画线 + 右侧面板取最新值共用。
|
||||
// alignedKey: alignedSeries 中的 key(由 series.boll/keltner/atr 对齐而来)
|
||||
// group: 属于哪个价位开关组(开关该组即开关这条曲线)
|
||||
// endLabel: 右侧端点标签(显示最新值的文字)
|
||||
const CURVE_DEFS: { alignedKey: string; group: LevelType; endLabel: string; color: string; dashed?: boolean }[] = [
|
||||
{ alignedKey: 'boll_upper', group: 'boll', endLabel: '布林上轨', color: '#F97316', dashed: true },
|
||||
{ alignedKey: 'boll_lower', group: 'boll', endLabel: '布林下轨', color: '#F97316', dashed: true },
|
||||
{ alignedKey: 'boll_mid', group: 'boll', endLabel: '布林中轨', color: '#FB923C', dashed: false },
|
||||
{ alignedKey: 'keltner_s_upper',group: 'keltner_s', endLabel: 'Keltner短上', color: '#06B6D4', dashed: true },
|
||||
{ alignedKey: 'keltner_s_lower',group: 'keltner_s', endLabel: 'Keltner短下', color: '#06B6D4', dashed: true },
|
||||
{ alignedKey: 'keltner_m_upper',group: 'keltner_m', endLabel: 'Keltner中上', color: '#22D3EE', dashed: true },
|
||||
{ alignedKey: 'keltner_m_lower',group: 'keltner_m', endLabel: 'Keltner中下', color: '#22D3EE', dashed: true },
|
||||
{ alignedKey: 'keltner_l_upper',group: 'keltner_l', endLabel: 'Keltner长上', color: '#67E8F9', dashed: true },
|
||||
{ alignedKey: 'keltner_l_lower',group: 'keltner_l', endLabel: 'Keltner长下', color: '#67E8F9', dashed: true },
|
||||
{ alignedKey: 'atr_stop', group: 'atr_stop', endLabel: 'ATR止损', color: '#EF4444', dashed: true },
|
||||
{ alignedKey: 'atr_tp', group: 'atr_stop', endLabel: 'ATR止盈', color: '#F87171', dashed: true },
|
||||
]
|
||||
|
||||
// ===== 预留:标记 / 区间(后续新闻面、事件区间用) =====
|
||||
export interface ChartMarker {
|
||||
date: string
|
||||
label?: string
|
||||
color?: string
|
||||
above?: boolean
|
||||
}
|
||||
export interface ChartRange {
|
||||
start: string
|
||||
end: string
|
||||
label?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: KlineRow[]
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
/** 带状曲线指标(布林带/Keltner/ATR)的每日序列 —— 画成跟随时间漂移的曲线 */
|
||||
series?: LevelSeries
|
||||
/** series 数据对应的日期数组(与 series 各数组对齐) */
|
||||
seriesDates?: string[]
|
||||
/** 默认开启的价位组 */
|
||||
defaultLevelTypes?: LevelType[]
|
||||
/** 预留:新闻/暴雷/利好日期标记 */
|
||||
markers?: ChartMarker[]
|
||||
/** 预留:事件区间高亮 */
|
||||
ranges?: ChartRange[]
|
||||
/** 预留:点击某根 K 线 */
|
||||
onDateClick?: (date: string) => void
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const VOL_PANE_H = 90
|
||||
|
||||
export function AnalysisKChart({
|
||||
rows,
|
||||
levels,
|
||||
series,
|
||||
seriesDates,
|
||||
defaultLevelTypes = ['sr', 'pivot', 'keltner_s'],
|
||||
markers,
|
||||
ranges,
|
||||
onDateClick,
|
||||
height = 460,
|
||||
className,
|
||||
}: Props) {
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const chartInstRef = useRef<ECharts | null>(null)
|
||||
const [activeTypes, setActiveTypes] = useState<Set<LevelType>>(new Set(defaultLevelTypes))
|
||||
/** 枢轴点显示到第几档:1=只P+R1/S1, 2=到R2/S2, 3=全档(R3/S3) */
|
||||
const [pivotRank, setPivotRank] = useState<1 | 2 | 3>(1)
|
||||
|
||||
// 数据预处理 + 带状曲线序列对齐(后端 series 的日期范围可能与 rows 不同,需映射)
|
||||
const { dates, candle, vols, dateIndex, zoomStart, alignedSeries } = useMemo(() => {
|
||||
const dates = rows.map(r => (typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date)))
|
||||
const candle = rows.map(r => [r.open, r.close, r.low, r.high])
|
||||
const vols = rows.map(r => ({
|
||||
value: r.volume ?? 0,
|
||||
itemStyle: { color: r.close >= r.open ? THEME.volUp : THEME.volDown },
|
||||
}))
|
||||
const dateIndex = new Map(dates.map((d, i) => [d, i]))
|
||||
// 默认显示最近 6 个月 ≈ 120 个交易日;数据不足则全部显示
|
||||
const showBars = 120
|
||||
const zoomStart = dates.length > showBars ? Math.round((1 - showBars / dates.length) * 100) : 0
|
||||
|
||||
// 把后端 series(按 seriesDates 对齐)映射到前端 rows 的 dates 顺序
|
||||
const alignedSeries: Record<string, (number | null)[]> = {}
|
||||
if (series && seriesDates && seriesDates.length > 0) {
|
||||
// 构建 seriesDates 索引
|
||||
const sIdx = new Map(seriesDates.map((d, i) => [d, i]))
|
||||
// 通用对齐:给定 series 里某条数组,返回与 rows dates 对齐的版本
|
||||
const align = (arr: (number | null)[] | undefined): (number | null)[] => {
|
||||
if (!arr) return dates.map(() => null)
|
||||
return dates.map(d => {
|
||||
const i = sIdx.get(d)
|
||||
return i != null ? arr[i] : null
|
||||
})
|
||||
}
|
||||
if (series.boll) {
|
||||
alignedSeries['boll_upper'] = align(series.boll.upper)
|
||||
alignedSeries['boll_lower'] = align(series.boll.lower)
|
||||
if (series.boll.mid) alignedSeries['boll_mid'] = align(series.boll.mid)
|
||||
}
|
||||
if (series.keltner_s) {
|
||||
alignedSeries['keltner_s_upper'] = align(series.keltner_s.upper)
|
||||
alignedSeries['keltner_s_lower'] = align(series.keltner_s.lower)
|
||||
}
|
||||
if (series.keltner_m) {
|
||||
alignedSeries['keltner_m_upper'] = align(series.keltner_m.upper)
|
||||
alignedSeries['keltner_m_lower'] = align(series.keltner_m.lower)
|
||||
}
|
||||
if (series.keltner_l) {
|
||||
alignedSeries['keltner_l_upper'] = align(series.keltner_l.upper)
|
||||
alignedSeries['keltner_l_lower'] = align(series.keltner_l.lower)
|
||||
}
|
||||
if (series.atr) {
|
||||
alignedSeries['atr_stop'] = align(series.atr.stop_loss)
|
||||
alignedSeries['atr_tp'] = align(series.atr.take_profit)
|
||||
}
|
||||
}
|
||||
|
||||
return { dates, candle, vols, dateIndex, zoomStart, alignedSeries }
|
||||
}, [rows, series, seriesDates])
|
||||
|
||||
// 构建 option
|
||||
const buildOption = (): EChartsOption => {
|
||||
const priceLines = collectPriceLines(levels, activeTypes, pivotRank)
|
||||
|
||||
// 三段布局:主图 / 成交量 / 缩放条,从上到下累加,各段之间留间距,互不遮挡
|
||||
// [16 顶部] [mainH 主图] [8 间距] [volH 成交量] [12 间距] [SLIDER_H 缩放条] [8 底部]
|
||||
const SLIDER_H = 22
|
||||
const PAD_TOP = 16
|
||||
const GAP_MAIN_VOL = 8 // 主图 ↔ 成交量
|
||||
const GAP_VOL_SLIDER = 12 // 成交量 ↔ 缩放条(留足,避免遮挡)
|
||||
const PAD_BOTTOM = 8
|
||||
const volH = VOL_PANE_H
|
||||
const mainH = height - PAD_TOP - GAP_MAIN_VOL - volH - GAP_VOL_SLIDER - SLIDER_H - PAD_BOTTOM
|
||||
const volTop = PAD_TOP + mainH + GAP_MAIN_VOL
|
||||
const sliderBottom = PAD_BOTTOM
|
||||
|
||||
// 预留:markPoint(新闻标记)
|
||||
const markPointData: any[] = (markers ?? [])
|
||||
.filter(m => dateIndex.has(m.date))
|
||||
.map(m => ({
|
||||
coord: [m.date, rows[dateIndex.get(m.date)!].high],
|
||||
symbol: 'pin', symbolSize: 32,
|
||||
itemStyle: { color: m.color ?? '#EAB308' },
|
||||
label: { show: !!m.label, formatter: m.label ?? '', fontSize: 9, color: '#fff' },
|
||||
}))
|
||||
|
||||
// 预留:markArea(事件区间)
|
||||
const markAreaData: any[] = (ranges ?? [])
|
||||
.filter(r => dateIndex.has(r.start) && dateIndex.has(r.end))
|
||||
.map(r => [{
|
||||
xAxis: r.start, name: r.label ?? '',
|
||||
itemStyle: { color: r.color ?? 'rgba(234,179,8,0.08)' },
|
||||
label: r.label ? { show: true, position: 'insideTop', distance: 6, color: '#EAB308', fontSize: 10 } : undefined,
|
||||
}, { xAxis: r.end }])
|
||||
|
||||
const series: any[] = [
|
||||
{
|
||||
name: 'K', type: 'candlestick', data: candle, animation: false,
|
||||
itemStyle: {
|
||||
color: THEME.bull, color0: THEME.bear,
|
||||
borderColor: THEME.bull, borderColor0: THEME.bear,
|
||||
},
|
||||
markPoint: markPointData.length ? { data: markPointData, animation: false } : undefined,
|
||||
markArea: markAreaData.length ? { silent: true, data: markAreaData } : undefined,
|
||||
},
|
||||
{
|
||||
name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1,
|
||||
data: vols, animation: false,
|
||||
},
|
||||
]
|
||||
|
||||
// 价位水平线 —— 用 line series(恒定值)画水平线,endLabel 显示标签文字;
|
||||
// 与通道曲线一致,标签落在右侧 grid.right 预留带(外侧),不压蜡烛。
|
||||
for (const p of priceLines) {
|
||||
series.push({
|
||||
name: p.label, type: 'line', silent: true, animation: false,
|
||||
symbol: 'none',
|
||||
data: dates.map(() => p.value),
|
||||
lineStyle: { width: 1, color: p.color, type: 'dashed', opacity: 0.7 },
|
||||
itemStyle: { color: p.color },
|
||||
endLabel: {
|
||||
show: true,
|
||||
formatter: () => `${p.label} ${p.value.toFixed(2)}`,
|
||||
color: p.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
|
||||
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
|
||||
distance: 6,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 带状曲线指标(布林带 / Keltner通道 / ATR止损) —— 跟随行情漂移的曲线
|
||||
// 单一数据源 CURVE_DEFS 驱动:每条曲线带 endLabel(右侧端点标签),显示最新数值
|
||||
for (const def of CURVE_DEFS) {
|
||||
if (!activeTypes.has(def.group)) continue
|
||||
const data = alignedSeries[def.alignedKey]
|
||||
if (!data || !data.some(v => v != null)) continue
|
||||
// 取最后一个有效值作为右侧端点显示文字
|
||||
let lastVal: number | null = null
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
if (data[i] != null) { lastVal = data[i]; break }
|
||||
}
|
||||
series.push({
|
||||
name: def.endLabel, type: 'line', data: data.map(v => v ?? '-'),
|
||||
smooth: true, symbol: 'none', silent: true, animation: false,
|
||||
lineStyle: { width: 1, color: def.color, type: def.dashed === false ? 'solid' : 'dashed', opacity: 0.8 },
|
||||
itemStyle: { color: def.color },
|
||||
// 右侧端点标签:显示该通道的最新数值,距绘图区右缘留 6px 间距
|
||||
endLabel: lastVal != null ? {
|
||||
show: true,
|
||||
formatter: () => `${lastVal!.toFixed(2)}`,
|
||||
color: def.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
|
||||
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
|
||||
distance: 6,
|
||||
} : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
// grid.right 留出足够宽度给价位标签文字区:蜡烛只占左侧主区域,
|
||||
// 价位线右端的标签文字显示在这条预留带里,不压在蜡烛上。
|
||||
// 预留 ~144px:最长标签(如「成交密集区(POC) 12.34」)约 13 字符,fontSize 9 等宽。
|
||||
grid: [
|
||||
{ left: 56, right: 144, top: 16, height: mainH },
|
||||
{ left: 56, right: 144, top: volTop, height: volH },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category', data: dates, boundaryGap: true,
|
||||
axisLine: { lineStyle: { color: THEME.grid } },
|
||||
axisLabel: { color: THEME.text, fontSize: 10 },
|
||||
splitLine: { show: false },
|
||||
axisPointer: { show: true, label: { show: false } },
|
||||
},
|
||||
{
|
||||
type: 'category', gridIndex: 1, data: dates, boundaryGap: true,
|
||||
axisLabel: { show: false }, axisLine: { show: false }, axisTick: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{ scale: true, splitLine: { lineStyle: { color: THEME.grid } },
|
||||
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' } },
|
||||
{ scale: true, gridIndex: 1, splitNumber: 2,
|
||||
// 成交量区不画背景横线
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: THEME.text, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
|
||||
formatter: (v: number) => fmtVol(v) } },
|
||||
],
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1], start: zoomStart, end: 100 },
|
||||
{ type: 'slider', xAxisIndex: [0, 1], bottom: sliderBottom, height: SLIDER_H, start: zoomStart, end: 100,
|
||||
borderColor: 'transparent', fillerColor: 'rgba(255,255,255,0.06)',
|
||||
handleStyle: { color: '#52525B' }, textStyle: { color: THEME.text, fontSize: 10 } },
|
||||
],
|
||||
// 不弹 hover tooltip(用户要求);但保留十字线 axisPointer 作为缩放/定位参照
|
||||
tooltip: { show: false },
|
||||
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||
series,
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 + 数据更新
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return
|
||||
if (!chartInstRef.current) {
|
||||
chartInstRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
|
||||
chartInstRef.current.on('click', (params: any) => {
|
||||
// 预留:点击 K 线(非 markPoint/markLine)回调
|
||||
if (params.componentType === 'series' && params.seriesType === 'candlestick' && onDateClick) {
|
||||
onDateClick(dates[params.dataIndex])
|
||||
}
|
||||
})
|
||||
}
|
||||
chartInstRef.current.setOption(buildOption(), true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, levels, series, seriesDates, activeTypes, pivotRank, markers, ranges, height])
|
||||
|
||||
// resize
|
||||
useEffect(() => {
|
||||
const inst = chartInstRef.current
|
||||
if (!inst) return
|
||||
const onResize = () => inst.resize()
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => { window.removeEventListener('resize', onResize); inst.dispose(); chartInstRef.current = null }
|
||||
}, [])
|
||||
|
||||
const toggleType = (t: LevelType) => {
|
||||
setActiveTypes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(t)) next.delete(t)
|
||||
else next.add(t)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* 价位开关按钮组 */}
|
||||
{levels && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-2">
|
||||
<span className="text-[10px] text-muted mr-1">关键价位</span>
|
||||
{LEVEL_GROUPS.map(g => {
|
||||
const active = activeTypes.has(g.key)
|
||||
// 枢轴点数量按当前档位过滤显示;其他组显示原始数量
|
||||
const raw = levels[g.key] ?? []
|
||||
const count = g.key === 'pivot'
|
||||
? raw.filter(p => p.rank === undefined || p.rank <= pivotRank).length
|
||||
: raw.length
|
||||
return (
|
||||
<button
|
||||
key={g.key}
|
||||
onClick={() => toggleType(g.key)}
|
||||
disabled={raw.length === 0}
|
||||
title={`${g.label} (${count} 个)`}
|
||||
className={`inline-flex items-center gap-1 h-6 px-2 rounded-md text-[10px] font-medium border transition-all disabled:opacity-30 disabled:cursor-not-allowed ${
|
||||
active
|
||||
? 'text-foreground'
|
||||
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
|
||||
}`}
|
||||
style={active ? { borderColor: g.color + '66', backgroundColor: g.color + '1a' } : undefined}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: active ? g.color : '#52525B' }} />
|
||||
{g.label}
|
||||
<span className="opacity-50">{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 枢轴点档位选择器 —— 仅当枢轴点开启时显示 */}
|
||||
{activeTypes.has('pivot') && (levels.pivot?.length ?? 0) > 0 && (
|
||||
<div className="inline-flex items-center gap-0.5 ml-1 pl-2 border-l border-border/40">
|
||||
<span className="text-[10px] text-muted mr-1">档位</span>
|
||||
{([1, 2, 3] as const).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setPivotRank(r)}
|
||||
title={r === 1 ? 'P + R1/S1(3 个)' : r === 2 ? '到 R2/S2(5 个)' : '全档 R3/S3(7 个)'}
|
||||
className={`h-6 px-2 rounded-md text-[10px] font-mono border transition-all ${
|
||||
pivotRank === r
|
||||
? 'bg-[#8B5CF6]/15 border-[#8B5CF6]/40 text-[#c4b5fd]'
|
||||
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 图表:右侧预留带(grid.right 预留)显示价位标签文字,不压蜡烛 */}
|
||||
<div ref={chartRef} style={{ width: '100%', height }} />
|
||||
|
||||
{/* 价位统计面板:把当前开启的点位按"压力 / 支撑"结构化列出 */}
|
||||
{levels && (
|
||||
<LevelOverview
|
||||
levels={levels}
|
||||
activeTypes={activeTypes}
|
||||
pivotRank={pivotRank}
|
||||
close={rows.length ? rows[rows.length - 1].close : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 价位统计面板(图表下方,结构化文本展示) =====
|
||||
function LevelOverview({
|
||||
levels, activeTypes, pivotRank, close,
|
||||
}: {
|
||||
levels: Record<LevelType, PriceLevel[]>
|
||||
activeTypes: Set<LevelType>
|
||||
pivotRank: 1 | 2 | 3
|
||||
close?: number
|
||||
}) {
|
||||
// 收集当前显示的点位(同 collectPriceLines 的过滤逻辑)
|
||||
const visible: PriceLevel[] = []
|
||||
for (const g of LEVEL_GROUPS) {
|
||||
if (!activeTypes.has(g.key)) continue
|
||||
for (const p of levels[g.key] ?? []) {
|
||||
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
|
||||
visible.push(p)
|
||||
}
|
||||
}
|
||||
if (visible.length === 0) return null
|
||||
|
||||
// 按方向分两组:压力位(在当前价之上) / 支撑位(之下),各自按距当前价远近排序
|
||||
const cur = close ?? visible[0].value
|
||||
const resistances = visible
|
||||
.filter(p => p.side === 'resistance')
|
||||
.sort((a, b) => a.value - b.value) // 由近及远(低→高)
|
||||
const supports = visible
|
||||
.filter(p => p.side === 'support')
|
||||
.sort((a, b) => b.value - a.value) // 由近及远(高→低)
|
||||
const neutrals = visible.filter(p => p.side === 'neutral')
|
||||
|
||||
const fmtPct = (v: number) => {
|
||||
if (!cur) return ''
|
||||
const pct = ((v - cur) / cur) * 100
|
||||
const sign = pct >= 0 ? '+' : ''
|
||||
return `${sign}${pct.toFixed(1)}%`
|
||||
}
|
||||
|
||||
const Row = ({ p }: { p: PriceLevel }) => {
|
||||
const color = LEVEL_GROUPS.find(g => g.key === p.type)?.color ?? THEME.text
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||
<span className="text-[11px] text-secondary w-24 shrink-0 truncate">{p.label}</span>
|
||||
<span className="text-[11px] font-mono text-foreground">{p.value.toFixed(2)}</span>
|
||||
<span className="text-[9px] font-mono text-muted">{fmtPct(p.value)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 rounded-lg border border-border/40 bg-base/20 px-3 py-2">
|
||||
{/* 当前价 */}
|
||||
<div className="sm:col-span-2 flex items-center gap-2 pb-1 border-b border-border/30 mb-0.5">
|
||||
<span className="text-[10px] text-muted">当前价</span>
|
||||
<span className="text-xs font-mono font-medium text-foreground">{cur.toFixed(2)}</span>
|
||||
</div>
|
||||
{/* 压力位(从近到远,即从低到高)倒序展示:最高的在最上 */}
|
||||
{resistances.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-bear mb-0.5">压力位 ↑</div>
|
||||
{[...resistances].reverse().map((p, i) => <Row key={`r-${i}`} p={p} />)}
|
||||
</div>
|
||||
)}
|
||||
{/* 支撑位 + 中性(枢轴位 P) */}
|
||||
<div>
|
||||
{supports.length > 0 && (
|
||||
<>
|
||||
<div className="text-[10px] font-medium text-bull mb-0.5">支撑位 ↓</div>
|
||||
{supports.map((p, i) => <Row key={`s-${i}`} p={p} />)}
|
||||
</>
|
||||
)}
|
||||
{neutrals.length > 0 && (
|
||||
<div className={supports.length > 0 ? 'mt-2' : ''}>
|
||||
{supports.length === 0 && <div className="text-[10px] font-medium text-muted mb-0.5">枢轴位</div>}
|
||||
{neutrals.map((p, i) => <Row key={`n-${i}`} p={p} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 工具:收集要画的水平价位线(按开启的组 + 档位 + 强度配色) =====
|
||||
// 注意:带状指标(布林带/Keltner/ATR)改用曲线渲染,不在此画水平线,避免重复。
|
||||
function collectPriceLines(
|
||||
levels: Record<LevelType, PriceLevel[]> | undefined,
|
||||
active: Set<LevelType>,
|
||||
pivotRank: 1 | 2 | 3,
|
||||
): { value: number; label: string; color: string }[] {
|
||||
if (!levels) return []
|
||||
const out: { value: number; label: string; color: string }[] = []
|
||||
for (const g of LEVEL_GROUPS) {
|
||||
if (!active.has(g.key)) continue
|
||||
for (const p of levels[g.key] ?? []) {
|
||||
// 枢轴点:按档位过滤(rank>P 的,只显示到选定的档位)
|
||||
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
|
||||
// 波动通道类(boll / keltner三档 / atr_stop)整组走曲线渲染,不画水平线;
|
||||
// sr 组现为成交密集区水平点,直接画线即可,无需特判。
|
||||
if (p.type === 'boll' || p.type === 'keltner_s' || p.type === 'keltner_m'
|
||||
|| p.type === 'keltner_l' || p.type === 'atr_stop') continue
|
||||
out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color) })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function strengthColor(strength: string | undefined, base: string): string {
|
||||
// strong 用实色,medium 用 0.85,weak 用 0.55 透明
|
||||
if (strength === 'weak') return base + '8C'
|
||||
if (strength === 'medium') return base + 'D9'
|
||||
return base
|
||||
}
|
||||
|
||||
function fmtVol(v: number): string {
|
||||
if (!v) return '0'
|
||||
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
|
||||
if (v >= 1e4) return (v / 1e4).toFixed(0) + '万'
|
||||
return v.toFixed(0)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Loader2, Check, AlertCircle } from 'lucide-react'
|
||||
import { useBubbleTasks, restoreDialog } from '@/lib/stockAnalysisStore'
|
||||
import type { ActiveTask } from '@/lib/stockAnalysisStore'
|
||||
|
||||
/**
|
||||
* AI 个股分析任务全局气泡 —— 与财务分析胶囊并列,蓝色主题区分。
|
||||
* 挂在右侧,拖拽丝滑(逻辑同 AiReportBubble,独立状态池)。
|
||||
*
|
||||
* 与财务胶囊的差异:蓝色系 + "个股分析中"文案,避免与财务分析混淆。
|
||||
*/
|
||||
|
||||
const BUBBLE_W = 148
|
||||
const EDGE_MARGIN = 12
|
||||
|
||||
export function StockAnalysisBubble() {
|
||||
const activeTasks = useBubbleTasks()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos())
|
||||
|
||||
const draggingRef = useRef(false)
|
||||
const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 })
|
||||
const movedRef = useRef(false)
|
||||
const clickTargetRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const applyTransform = useCallback((x: number, y: number) => {
|
||||
const el = containerRef.current
|
||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)`
|
||||
}, [])
|
||||
|
||||
const clamp = useCallback((x: number, y: number) => {
|
||||
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
|
||||
const maxY = window.innerHeight - 80
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
draggingRef.current = true
|
||||
movedRef.current = false
|
||||
dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y }
|
||||
const el = containerRef.current
|
||||
if (el) el.classList.add('dragging')
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}, [pos.x, pos.y])
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return
|
||||
const dx = e.clientX - dragData.current.mx
|
||||
const dy = e.clientY - dragData.current.my
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
|
||||
const c = clamp(dragData.current.ox + dx, dragData.current.oy + dy)
|
||||
applyTransform(c.x, c.y)
|
||||
}, [clamp, applyTransform])
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
const el = containerRef.current
|
||||
if (el) el.classList.remove('dragging')
|
||||
if (movedRef.current) {
|
||||
setPos(prev => {
|
||||
const transform = el?.style.transform ?? ''
|
||||
const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/)
|
||||
const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev
|
||||
savePos(finalPos)
|
||||
return finalPos
|
||||
})
|
||||
} else {
|
||||
const fn = clickTargetRef.current
|
||||
clickTargetRef.current = null
|
||||
fn?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setPos(prev => {
|
||||
const c = clamp(prev.x, prev.y)
|
||||
if (c.x !== prev.x || c.y !== prev.y) {
|
||||
applyTransform(c.x, c.y)
|
||||
return c
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [clamp, applyTransform])
|
||||
|
||||
useEffect(() => { applyTransform(pos.x, pos.y) }, [pos.x, pos.y, applyTransform])
|
||||
|
||||
if (activeTasks.length === 0) return null
|
||||
|
||||
// 默认位置偏下,避免与财务胶囊(右下)重叠
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="sa-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
width: `${BUBBLE_W}px`,
|
||||
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
|
||||
touchAction: 'none',
|
||||
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{activeTasks.map((task, i) => (
|
||||
<BubbleItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isLast={i === activeTasks.length - 1}
|
||||
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<style>{`.sa-bubble-root.dragging { transition: none !important; }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BubbleItem({ task, isLast, onPointerDown }: {
|
||||
task: ActiveTask
|
||||
isLast: boolean
|
||||
onPointerDown: () => void
|
||||
}) {
|
||||
const isWorking = task.phase === 'loading' || task.phase === 'streaming'
|
||||
const isError = task.phase === 'error'
|
||||
|
||||
// 蓝色系(区别于财务分析的紫色)
|
||||
const accent = isWorking
|
||||
? 'from-sky-500/25 to-blue-500/20 text-sky-300 border-sky-300/40 shadow-[0_6px_24px_-10px_rgba(14,165,233,0.5)]'
|
||||
: isError
|
||||
? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]'
|
||||
: 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -8 }}
|
||||
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
|
||||
className={isLast ? '' : 'mb-1.5'}
|
||||
>
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={isWorking ? '个股分析中,点击恢复' : isError ? '分析失败,点击重试' : '点击查看个股分析报告'}
|
||||
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
|
||||
>
|
||||
{isWorking && (
|
||||
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
|
||||
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-sky-200 to-transparent animate-sa-bubble-progress" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex h-4 w-4 items-center justify-center shrink-0">
|
||||
{isWorking ? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: isError ? <AlertCircle className="h-3 w-3" />
|
||||
: <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
|
||||
{task.name || task.symbol}
|
||||
</span>
|
||||
<span className="shrink-0 text-[9px] leading-none">
|
||||
{isWorking ? <span className="text-sky-300/80">个股分析</span>
|
||||
: isError ? <span className="text-red-300/80">失败</span>
|
||||
: <span className="text-emerald-300/80">点击查看</span>}
|
||||
</span>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes sa-bubble-progress { 0% { transform: translateX(-100%); } 100% { transform: translateX(300%); } }
|
||||
.animate-sa-bubble-progress { animation: sa-bubble-progress 1.6s ease-in-out infinite; }
|
||||
`}</style>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const POS_KEY = 'sa_bubble_pos'
|
||||
function loadPos(): { x: number; y: number } {
|
||||
// 默认右下,偏上一点(避开财务胶囊的右下位置)
|
||||
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
|
||||
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 320)
|
||||
try {
|
||||
const v = localStorage.getItem(POS_KEY)
|
||||
if (v) {
|
||||
const p = JSON.parse(v)
|
||||
if (typeof p.x === 'number' && typeof p.y === 'number') {
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { x: defaultX, y: defaultY }
|
||||
}
|
||||
function savePos(p: { x: number; y: number }) {
|
||||
try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
X, Sparkles, Loader2, AlertTriangle, Copy, Check, RefreshCw,
|
||||
Settings2, Send, Wand2, Minimize2, History, LineChart,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
||||
import {
|
||||
type ActiveTask, type HistoryReport,
|
||||
minimizeDialog, closeDialog, startAnalysis,
|
||||
} from '@/lib/stockAnalysisStore'
|
||||
|
||||
/**
|
||||
* AI 个股分析对话框 —— 蓝色主题,与财务分析对话框区分。
|
||||
* 复用 MarkdownRenderer(通用 markdown 渲染);标题/配色/文案独立。
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
task: ActiveTask | HistoryReport | null
|
||||
mode: 'active' | 'history' | null
|
||||
minimized: boolean
|
||||
}
|
||||
|
||||
type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
function getPhase(task: ActiveTask | HistoryReport | null): Phase {
|
||||
if (!task) return 'loading'
|
||||
if ('phase' in task) return task.phase
|
||||
return 'done'
|
||||
}
|
||||
function getContent(task: ActiveTask | HistoryReport | null): string {
|
||||
return task?.content ?? ''
|
||||
}
|
||||
function getMeta(task: ActiveTask | HistoryReport | null) {
|
||||
if (!task) return null
|
||||
if ('meta' in task) return task.meta
|
||||
return { summary: task.summary, close: task.close, levels: task.levels }
|
||||
}
|
||||
|
||||
export function StockAnalysisDialog({ task, mode, minimized }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [focus, setFocus] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const phase = getPhase(task)
|
||||
const content = getContent(task)
|
||||
const meta = getMeta(task)
|
||||
const isHistory = mode === 'history'
|
||||
const isWorking = phase === 'loading' || phase === 'streaming'
|
||||
const open = !!task && !minimized
|
||||
|
||||
useEffect(() => {
|
||||
if (open && phase === 'streaming' && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [content, phase, open])
|
||||
|
||||
useEffect(() => {
|
||||
setFocus(task && 'focus' in task ? task.focus : '')
|
||||
}, [task])
|
||||
|
||||
const handleStartNew = useCallback(async () => {
|
||||
if (!task) return
|
||||
const name = 'name' in task ? task.name : ''
|
||||
await startAnalysis(task.symbol, name, focus.trim())
|
||||
}, [task, focus])
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!content) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const error = task && 'error' in task ? task.error : ''
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
|
||||
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
|
||||
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* 头部 —— 蓝色主题 */}
|
||||
<div className="relative px-5 py-3.5 border-b border-border/50 bg-gradient-to-r from-sky-500/[0.06] via-blue-500/[0.04] to-transparent">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500/20 to-blue-500/15 border border-sky-400/30 shrink-0">
|
||||
{isHistory
|
||||
? <History className="h-4.5 w-4.5 text-sky-300" />
|
||||
: <LineChart className="h-4.5 w-4.5 text-sky-300" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground truncate">
|
||||
{isHistory ? '历史分析报告' : 'AI 个股分析'}
|
||||
</span>
|
||||
{task && <span className="text-xs text-secondary truncate">{task.name}</span>}
|
||||
{task && <span className="text-[10px] font-mono text-muted shrink-0">{task.symbol}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted">
|
||||
{meta?.summary ? (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Sparkles className="h-2.5 w-2.5 shrink-0" />
|
||||
<span className="truncate">{meta.summary}</span>
|
||||
</span>
|
||||
) : isWorking ? <span>正在读取行情与价位数据…</span> : null}
|
||||
{phase === 'streaming' && (
|
||||
<span className="flex items-center gap-1 text-sky-300 shrink-0">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-sky-400 animate-pulse" />生成中
|
||||
</span>
|
||||
)}
|
||||
{isHistory && task && 'created_at' in task && (
|
||||
<span className="shrink-0">{fmtRelative(task.created_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{content && !isWorking && (
|
||||
<button onClick={handleCopy} title="复制全文"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
{!isHistory && isWorking && (
|
||||
<button onClick={minimizeDialog} title="最小化为气泡,后台继续生成"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{(!isWorking || isHistory) && (
|
||||
<button onClick={closeDialog} title="关闭"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 min-h-[280px]">
|
||||
{phase === 'loading' && !content && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div className="relative">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-sky-500/20 to-blue-500/15 border border-sky-400/30 flex items-center justify-center">
|
||||
<LineChart className="h-4.5 w-4.5 text-sky-300 animate-pulse" />
|
||||
</div>
|
||||
<Loader2 className="absolute -inset-1 h-12 w-12 text-sky-400/40 animate-spin" style={{ animationDuration: '3s' }} />
|
||||
</div>
|
||||
<div className="text-xs text-secondary">AI 正在分析行情与关键价位…</div>
|
||||
<div className="text-[10px] text-muted">读取日 K / 技术指标 / 压力支撑 / 财务,生成四维分析</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'error' && (
|
||||
<div className="flex flex-col items-center justify-center py-14 gap-3">
|
||||
<div className="h-11 w-11 rounded-full bg-danger/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">分析失败</div>
|
||||
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
|
||||
{error.includes('AI') && (
|
||||
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
|
||||
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
|
||||
<Settings2 className="h-3.5 w-3.5" /> 去配置 AI
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleStartNew}
|
||||
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-sky-500/15 border border-sky-400/30 text-xs text-sky-300 hover:bg-sky-500/20 transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" /> 重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(content || phase === 'streaming') && (
|
||||
<div className="relative">
|
||||
<MarkdownRenderer content={content} />
|
||||
{phase === 'streaming' && (
|
||||
<span className="inline-block w-1.5 h-3.5 bg-sky-400 ml-0.5 align-middle animate-pulse rounded-sm" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:关注点输入 */}
|
||||
<div className="border-t border-border/50 bg-surface/60 px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted shrink-0">
|
||||
<Wand2 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">关注重点</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={focus}
|
||||
onChange={e => setFocus(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && (phase === 'done' || phase === 'error' || isHistory)) handleStartNew() }}
|
||||
disabled={isWorking}
|
||||
placeholder={isHistory ? '修改关注重点,回车重新生成' : (phase === 'done' ? '如:重点看能否突破压力位…回车重新分析' : '可留空,留空则全面分析')}
|
||||
className={cn(
|
||||
'flex-1 h-8 px-3 rounded-lg bg-base ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-sky-400/30 transition-shadow disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
{isHistory ? (
|
||||
<button
|
||||
onClick={handleStartNew}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs font-medium text-sky-300 hover:from-sky-500/30 hover:to-blue-500/20 transition-all shrink-0"
|
||||
title="以此关注点重新生成新报告"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />重新生成
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartNew}
|
||||
disabled={isWorking}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs font-medium text-sky-300 hover:from-sky-500/30 hover:to-blue-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0"
|
||||
title={focus.trim() ? '按关注重点重新分析' : '重新分析'}
|
||||
>
|
||||
{isWorking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : phase === 'done' ? <RefreshCw className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
|
||||
{phase === 'done' ? '重新分析' : '分析'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] text-muted/50 leading-relaxed">
|
||||
{isHistory
|
||||
? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
|
||||
: '报告由项目已配置的 AI 模型基于本地行情与财务数据生成;消息面维度暂依据价量异动推断。报告仅供参考,不构成投资建议。'}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtRelative(iso: string): string {
|
||||
try {
|
||||
const t = new Date(iso).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
} catch { return '' }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useDialogTask, useDialogState } from '@/lib/stockAnalysisStore'
|
||||
import { StockAnalysisDialog } from './StockAnalysisDialog'
|
||||
|
||||
/**
|
||||
* AI 个股分析对话框宿主 —— 单点挂载在 Layout。
|
||||
* 与财务分析的 AiAnalysisHost 并列,独立 store,蓝色主题。
|
||||
*/
|
||||
export function StockAnalysisHost() {
|
||||
const { task, mode } = useDialogTask()
|
||||
const { minimized } = useDialogState()
|
||||
return <StockAnalysisDialog task={task} mode={mode} minimized={minimized} />
|
||||
}
|
||||
@@ -0,0 +1,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,336 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
/**
|
||||
* AI 财务分析 —— 全局任务/报告 store(与 UI 解耦)。
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. 流式接收逻辑在这里,与弹窗组件解耦 → 用户关闭/最小化弹窗,后台流照常累积。
|
||||
* 2. useSyncExternalStore 订阅 → 任意组件(弹窗、气泡、历史面板)实时同步。
|
||||
* 3. "活跃任务"上限 MAX_ACTIVE=3:同时进行的任务最多 3 个,超出拒绝新建。
|
||||
* (历史报告名额 MAX_REPORTS=20 在后端裁剪,与活跃任务名额分离)
|
||||
* 4. 同 symbol 已有活跃任务 → 直接聚焦那个,不新建第 2 个。
|
||||
* 5. 任务完成(收到 done 或 content 非空且流结束)→ 自动存后端 + 移入历史 + 弹窗可恢复为"历史模式"。
|
||||
*/
|
||||
|
||||
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
export interface ActiveTask {
|
||||
id: string // 任务 id(前端生成,与最终 report id 解耦)
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
phase: Phase
|
||||
content: string // 累积的 Markdown
|
||||
error: string
|
||||
meta: { summary?: string; periods?: number } | null
|
||||
createdAt: number // ms 时间戳
|
||||
savedReportId?: string // 完成后存到后端的报告 id
|
||||
doneAt?: number // 进入 done/error 态的时间戳(用于气泡过期清理)
|
||||
dismissed?: boolean // 用户已从气泡点击查看过 → 不再在气泡显示
|
||||
}
|
||||
|
||||
export interface HistoryReport {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
content: string
|
||||
periods?: number
|
||||
summary?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const MAX_ACTIVE = 3
|
||||
|
||||
// ===== 全局状态 =====
|
||||
let activeTasks: ActiveTask[] = []
|
||||
let history: HistoryReport[] = []
|
||||
let historyLoaded = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
// 当前"前台"展示的任务:
|
||||
// - 活跃任务 id(正在生成/刚完成,对话框打开)
|
||||
// - 或 'history:<id>'(查看历史报告)
|
||||
// - 或 null(对话框关闭/最小化)
|
||||
let activeDialogTaskId: string | null = null
|
||||
let dialogMinimized = false // 对话框是否最小化为气泡
|
||||
|
||||
function emit() { listeners.forEach(fn => fn()) }
|
||||
|
||||
function subscribe(fn: () => void) {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
}
|
||||
|
||||
function normalizeAiError(msg: string) {
|
||||
return msg.includes('API Key') || msg.includes('api_key')
|
||||
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
|
||||
: msg
|
||||
}
|
||||
|
||||
// 快照必须返回稳定引用:只有内容真正变化时才返回新数组/对象。
|
||||
// useSyncExternalStore 用 Object.is 比较,getSnapshot 必须缓存。
|
||||
let _activeSnap: ActiveTask[] = []
|
||||
let _historySnap: HistoryReport[] = []
|
||||
interface DialogSnap { taskId: string | null; minimized: boolean }
|
||||
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
|
||||
function rebuildSnap() {
|
||||
_activeSnap = activeTasks
|
||||
_historySnap = history
|
||||
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
}
|
||||
|
||||
function getActiveSnapshot() { return _activeSnap }
|
||||
function getHistorySnapshot() { return _historySnap }
|
||||
function getDialogSnapshot() { return _dialogSnap }
|
||||
|
||||
function patchTask(id: string, patch: Partial<ActiveTask>) {
|
||||
activeTasks = activeTasks.map(t => {
|
||||
if (t.id !== id) return t
|
||||
const next = { ...t, ...patch }
|
||||
// 首次进入 done/error 态时记录 doneAt
|
||||
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
|
||||
next.doneAt = Date.now()
|
||||
}
|
||||
return next
|
||||
})
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
// ===== 公开:查询 hooks =====
|
||||
|
||||
export function useBubbleTasks(): ActiveTask[] {
|
||||
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
// 同时订阅对话框状态:最小化/打开/关闭会改变气泡可见性,需独立触发重渲染。
|
||||
// (否则最小化时 activeTasks 引用未变,useSyncExternalStore 不会重渲染,胶囊不出现)
|
||||
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
const ds = _dialogSnap
|
||||
return all.filter(t => {
|
||||
// 进行中:始终显示(dismissed 仅作用于完成态,不影响生成中的任务再次最小化)
|
||||
if (t.phase === 'loading' || t.phase === 'streaming') {
|
||||
// 除非对话框正打开看着它(非最小化)
|
||||
return !(ds.taskId === t.id && !ds.minimized)
|
||||
}
|
||||
// 完成/失败态:常驻显示,直到用户主动点击查看(dismissed)。
|
||||
// 不设自动过期 —— 胶囊是持续可见的状态指示器,历史报告列表是查看入口。
|
||||
if (t.dismissed) return false // 用户已点击查看过 → 移除
|
||||
if (!ds.minimized && ds.taskId === t.id) return false // 对话框正展示 → 不重复
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** 兼容旧调用名(Layout 等处可能引用) */
|
||||
export const useActiveTasks = useBubbleTasks
|
||||
|
||||
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
|
||||
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
return { reports, loaded: historyLoaded }
|
||||
}
|
||||
|
||||
export function useDialogState() {
|
||||
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
}
|
||||
|
||||
/** 当前对话框要展示的任务(活跃或历史),null=未打开。 */
|
||||
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
|
||||
const ds = useDialogState()
|
||||
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
if (!ds.taskId) return { task: null, mode: null }
|
||||
if (ds.taskId.startsWith('history:')) {
|
||||
const rid = ds.taskId.slice('history:'.length)
|
||||
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
|
||||
}
|
||||
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
|
||||
}
|
||||
|
||||
// ===== 公开:动作 =====
|
||||
|
||||
/** 拉取历史报告(惰性,首次需要时调用)。 */
|
||||
export async function loadHistory(): Promise<void> {
|
||||
try {
|
||||
const res = await api.financialReportsList()
|
||||
history = res.reports ?? []
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch {
|
||||
// 静默失败,列表会显示空
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某只股票最近一次的历史分析报告(用于二次确认提示)。
|
||||
* 若历史未加载,先触发拉取。
|
||||
* @returns 最近一条报告,或 null
|
||||
*/
|
||||
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
|
||||
if (!historyLoaded) await loadHistory()
|
||||
// history 已按 created_at 降序,取第一条匹配
|
||||
return history.find(r => r.symbol === symbol) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一个新的 AI 分析任务。
|
||||
* @returns 任务 id;若超出上限或已有活跃任务,返回 { error }。
|
||||
*/
|
||||
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
|
||||
// 同 symbol 已有活跃任务 → 直接聚焦它
|
||||
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
|
||||
if (existing) {
|
||||
activeDialogTaskId = existing.id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
return { id: existing.id }
|
||||
}
|
||||
// 上限检查
|
||||
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
|
||||
if (ongoing.length >= MAX_ACTIVE) {
|
||||
return { error: `同时进行的分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
|
||||
}
|
||||
|
||||
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const task: ActiveTask = {
|
||||
id, symbol, name, focus,
|
||||
phase: 'loading', content: '', error: '',
|
||||
meta: null, createdAt: Date.now(),
|
||||
}
|
||||
activeTasks = [...activeTasks, task]
|
||||
activeDialogTaskId = id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
|
||||
// 启动流式接收(后台运行,不阻塞)
|
||||
runStream(id, symbol, focus)
|
||||
return { id }
|
||||
}
|
||||
|
||||
async function runStream(id: string, symbol: string, focus: string) {
|
||||
try {
|
||||
let firstDelta = true
|
||||
for await (const chunk of api.financialAnalyzeStream(symbol, focus)) {
|
||||
// 任务可能已被取消(不在列表里了)→ 终止
|
||||
const cur = activeTasks.find(t => t.id === id)
|
||||
if (!cur) return
|
||||
switch (chunk.type) {
|
||||
case 'meta':
|
||||
patchTask(id, { meta: { summary: chunk.summary, periods: chunk.periods } })
|
||||
break
|
||||
case 'delta':
|
||||
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
|
||||
patchTask(id, { content: cur.content + (chunk.content ?? '') })
|
||||
break
|
||||
case 'error':
|
||||
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
|
||||
return
|
||||
case 'done':
|
||||
// 标记完成,稍后持久化(content 可能还在最后几个 delta 里,以 done 时为准)
|
||||
patchTask(id, { phase: 'done' })
|
||||
break
|
||||
}
|
||||
}
|
||||
// 流正常结束 → 持久化报告
|
||||
const final = activeTasks.find(t => t.id === id)
|
||||
if (final && final.phase !== 'error' && final.content) {
|
||||
try {
|
||||
const res = await api.financialReportSave({
|
||||
symbol: final.symbol,
|
||||
name: final.name,
|
||||
focus: final.focus,
|
||||
content: final.content,
|
||||
periods: final.meta?.periods,
|
||||
summary: final.meta?.summary ?? '',
|
||||
})
|
||||
if (res.report) {
|
||||
patchTask(id, { savedReportId: res.report.id })
|
||||
// 加到历史列表头部
|
||||
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
// 任务完成:不自动弹出对话框,只在胶囊显示"已完成"态,用户想看再点。
|
||||
// (若对话框正打开看此任务,内容已实时更新;最小化/在别处则胶囊亮起完成态)
|
||||
}
|
||||
} catch {
|
||||
// 持久化失败不影响前端已展示的内容
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message ?? '分析失败')
|
||||
patchTask(id, {
|
||||
phase: 'error',
|
||||
error: normalizeAiError(msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开对话框(活跃任务或历史报告)。 */
|
||||
export function openDialog(taskId: string) {
|
||||
activeDialogTaskId = taskId
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 最小化对话框 → 变成气泡。 */
|
||||
export function minimizeDialog() {
|
||||
dialogMinimized = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 关闭对话框(活跃任务继续在后台跑,仅移除对话框视图)。
|
||||
* 对历史报告:仅关闭视图。
|
||||
*/
|
||||
export function closeDialog() {
|
||||
activeDialogTaskId = null
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 从气泡恢复对话框。
|
||||
* 仅对已完成/失败的任务标记 dismissed(看过结果就不必再弹);
|
||||
* 生成中的任务不标记 —— 用户再次最小化时气泡应重新出现。
|
||||
*/
|
||||
export function restoreDialog(taskId: string) {
|
||||
const t = activeTasks.find(x => x.id === taskId)
|
||||
if (t && (t.phase === 'done' || t.phase === 'error')) {
|
||||
patchTask(taskId, { dismissed: true })
|
||||
}
|
||||
activeDialogTaskId = taskId
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 重试一个失败/已完成的任务(以新任务方式重新分析)。 */
|
||||
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
|
||||
return startAnalysis(task.symbol, task.name, task.focus)
|
||||
}
|
||||
|
||||
/** 删除历史报告。 */
|
||||
export async function deleteReport(reportId: string): Promise<void> {
|
||||
try {
|
||||
await api.financialReportDelete(reportId)
|
||||
history = history.filter(r => r.id !== reportId)
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开历史报告到对话框。 */
|
||||
export function openHistoryReport(reportId: string) {
|
||||
activeDialogTaskId = `history:${reportId}`
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
@@ -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: 'Free 可按标的查询实时行情,用于少量自选股监控' },
|
||||
'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 = 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 档显示英文「None」,其余档显示英文档名
|
||||
const display = base === 'none' ? 'None' : base
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-[18px] max-w-[80px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none ${className}`}
|
||||
style={t.tagBg}
|
||||
>
|
||||
<span className="truncate capitalize" style={t.labelTextStyle}>{display}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,118 @@
|
||||
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)
|
||||
if (v === null) return -1 // 从未设置过 → 未初始化
|
||||
return parseInt(v, 10) || 0
|
||||
} 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 {
|
||||
// total=0 视为未初始化, 不更新 (避免渲染期 data=undefined 传 0 重置 lastSeen)
|
||||
if (total <= 0) return
|
||||
|
||||
// 首次初始化: lastSeen <= 0 (从未设置 -1, 或历史为 0) → 把已读基线设为当前总数
|
||||
// 否则 lastSeen=0 + total=1 会被误算成"1条未读" (首次进入就显示徽标的 bug)
|
||||
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,91 @@
|
||||
/**
|
||||
* 集中管理所有 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,
|
||||
stockLevels: (symbol: string, days?: number) => ['stock-levels', symbol, days ?? 120] as const,
|
||||
klineMinute: (symbol: string, date: string) =>
|
||||
['kline-minute', symbol, date] as const,
|
||||
indexDaily: (symbol: string, start: string, end: string) =>
|
||||
['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,
|
||||
|
||||
// AI 大盘复盘
|
||||
reviewReports: ['review-reports'] as const,
|
||||
|
||||
// 概念涨幅轮动矩阵
|
||||
rpsRotation: (days: number) => ['rps-rotation', days] as const,
|
||||
} as const
|
||||
|
||||
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
|
||||
// 新增需要 SSE 推送的查询,只需在此加一行
|
||||
|
||||
export const SSE_INVALIDATE_PREFIXES = [
|
||||
'watchlist',
|
||||
'quote-status',
|
||||
'index-quotes',
|
||||
'overview-market',
|
||||
'limit-ladder',
|
||||
'screener',
|
||||
] as const
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 复盘生成状态的全局单例 store —— 脱离 Review 组件生命周期。
|
||||
*
|
||||
* 解决的问题:生成中切换到其他页面,Review 组件卸载会丢失 phase/content。
|
||||
* 本 store 把流式生成的状态提到模块级,组件卸载后流仍在后台继续跑,
|
||||
* 回到页面订阅即可恢复显示。
|
||||
*
|
||||
* 设计:
|
||||
* - 模块级 state(phase/content/meta/focus),唯一的生成实例
|
||||
* - AbortController 存模块级 ref,组件卸载不中断流
|
||||
* - 订阅者列表(notify 机制),Review mount 时订阅、unmount 时退订
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export type ReviewPhase = 'idle' | 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
export interface ReviewMeta {
|
||||
as_of?: string
|
||||
emotion_score?: number
|
||||
emotion_label?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
export interface ReviewState {
|
||||
phase: ReviewPhase
|
||||
content: string
|
||||
error: string
|
||||
meta: ReviewMeta | null
|
||||
focus: string
|
||||
}
|
||||
|
||||
const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null, focus: '' }
|
||||
|
||||
// ===== 模块级单例状态(组件卸载不销毁)=====
|
||||
let state: ReviewState = { ...INITIAL }
|
||||
let abortCtrl: AbortController | null = null
|
||||
|
||||
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
|
||||
// 用于区分两条流, 避免互相丢弃事件或重复归档。
|
||||
let generatingSource: 'manual' | 'sse' | null = null
|
||||
|
||||
// ===== 订阅机制 =====
|
||||
type Listener = () => void
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
function notify() {
|
||||
for (const l of listeners) l()
|
||||
}
|
||||
|
||||
export function getReviewState(): ReviewState {
|
||||
return state
|
||||
}
|
||||
|
||||
export function subscribeReview(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
// 暴露给组件直接读取最新 meta(用于自动归档,避免闭包取旧值)
|
||||
export function getReviewMeta(): ReviewMeta | null {
|
||||
return state.meta
|
||||
}
|
||||
|
||||
/** 是否正在生成(loading 或 streaming) */
|
||||
export function isReviewGenerating(): boolean {
|
||||
return state.phase === 'loading' || state.phase === 'streaming'
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动复盘生成。返回后流在后台独立运行,组件卸载不影响。
|
||||
* @param asOf 复盘日期
|
||||
* @param focus 用户追加的复盘关注点
|
||||
* @param onDone 完成回调(供调用方做自动归档)
|
||||
*/
|
||||
export async function startReviewGeneration(
|
||||
asOf: string | undefined,
|
||||
focus: string,
|
||||
onDone?: (fullContent: string, meta: ReviewMeta | null) => void,
|
||||
): Promise<void> {
|
||||
// 已在生成中,不重复启动
|
||||
if (isReviewGenerating()) return
|
||||
|
||||
generatingSource = 'manual'
|
||||
state = { phase: 'loading', content: '', error: '', meta: null, focus }
|
||||
notify()
|
||||
|
||||
abortCtrl = new AbortController()
|
||||
let buf = ''
|
||||
let failed = false
|
||||
let doneMeta: ReviewMeta | null = null
|
||||
|
||||
try {
|
||||
for await (const evt of api.reviewStream(asOf, focus)) {
|
||||
if (abortCtrl.signal.aborted) break
|
||||
if (evt.type === 'meta') {
|
||||
doneMeta = evt
|
||||
state = { ...state, meta: evt }
|
||||
notify()
|
||||
} else if (evt.type === 'delta' && evt.content) {
|
||||
buf += evt.content
|
||||
state = { ...state, content: buf, phase: 'streaming' }
|
||||
notify()
|
||||
} else if (evt.type === 'error') {
|
||||
failed = true
|
||||
state = { ...state, error: evt.message ?? '复盘失败', phase: 'error' }
|
||||
notify()
|
||||
return
|
||||
} else if (evt.type === 'done') {
|
||||
state = { ...state, phase: 'done' }
|
||||
notify()
|
||||
}
|
||||
}
|
||||
// 流正常结束但无 done 事件,按 done 处理
|
||||
if (buf && !failed) {
|
||||
state = { ...state, phase: 'done' }
|
||||
notify()
|
||||
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里)
|
||||
if (buf && !failed) {
|
||||
onDone?.(buf, doneMeta)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!abortCtrl.signal.aborted) {
|
||||
state = { ...state, error: e?.message ?? '复盘失败', phase: 'error' }
|
||||
notify()
|
||||
}
|
||||
} finally {
|
||||
abortCtrl = null
|
||||
generatingSource = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 中断当前生成(供"查看历史"等场景主动中断流)。 */
|
||||
export function abortReviewGeneration(): void {
|
||||
abortCtrl?.abort()
|
||||
abortCtrl = null
|
||||
}
|
||||
|
||||
/** 设置当前查看的历史报告(把 store 状态切到 done + 该报告内容)。 */
|
||||
export function setViewingReport(report: {
|
||||
content: string
|
||||
as_of?: string
|
||||
emotion_score?: number | null
|
||||
emotion_label?: string
|
||||
summary?: string
|
||||
}): void {
|
||||
abortCtrl?.abort()
|
||||
abortCtrl = null
|
||||
state = {
|
||||
phase: 'done',
|
||||
content: report.content,
|
||||
error: '',
|
||||
meta: {
|
||||
as_of: report.as_of,
|
||||
emotion_score: report.emotion_score ?? undefined,
|
||||
emotion_label: report.emotion_label,
|
||||
summary: report.summary,
|
||||
},
|
||||
focus: state.focus,
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
/** 重置到 idle(清空当前显示)。 */
|
||||
export function resetReview(): void {
|
||||
state = { ...INITIAL }
|
||||
notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* 喂入一条来自 SSE 的复盘事件(定时生成时后端推来的)。
|
||||
*
|
||||
* 用途: 定时复盘在后端流式生成, 通过 /api/intraday/stream 的 review_progress 事件
|
||||
* 把 meta/delta/done 等实时推给前端, 前端调本函数把事件写进 store ——
|
||||
* 这样开着复盘页的用户能看到「边生成边显示」, 和手动点生成完全一致。
|
||||
*
|
||||
* 事件格式与 recap_market_stream 产出一致:
|
||||
* {type:'meta'|'delta'|'error'|'done'|'retry', ...}
|
||||
*
|
||||
* 与手动生成的并发:
|
||||
* - 若手动正在生成(isReviewGenerating), 忽略 SSE 事件(手动流优先, 避免冲突)。
|
||||
* - done 带 archived=true(定时场景后端已归档): 不重复调归档接口, 仅切到 done 态。
|
||||
* - retry: 后端 LLM 断流重试, 清空已累积内容重新开始。
|
||||
*/
|
||||
export function feedReviewEvent(evt: any): void {
|
||||
if (!evt || typeof evt !== 'object') return
|
||||
const t = evt.type
|
||||
|
||||
// 并发控制: 手动流进行中时, SSE 事件一律忽略(手动流优先, 避免两条流抢同一个 store)
|
||||
// 但若当前是 SSE 流自己在跑(generatingSource==='sse'), 则正常处理后续事件
|
||||
if (generatingSource === 'manual') return
|
||||
|
||||
if (t === 'meta') {
|
||||
// 定时流的第一个事件: 标记来源为 sse, 进入 streaming 态, 重置 content
|
||||
generatingSource = 'sse'
|
||||
state = { phase: 'streaming', content: '', error: '', meta: evt, focus: '' }
|
||||
notify()
|
||||
} else if (t === 'delta' && evt.content) {
|
||||
// 只有 sse 流进行中时才累积(防止 meta 丢失时的孤立 delta)
|
||||
if (generatingSource !== 'sse') return
|
||||
state = { ...state, content: state.content + evt.content, phase: 'streaming' }
|
||||
notify()
|
||||
} else if (t === 'retry') {
|
||||
if (generatingSource !== 'sse') return
|
||||
// 后端重试: 清空已累积内容, 等待新一轮 meta/delta
|
||||
state = { ...state, content: '', phase: 'streaming' }
|
||||
notify()
|
||||
} else if (t === 'error') {
|
||||
if (generatingSource !== 'sse') return
|
||||
state = { ...state, error: evt.message ?? '复盘生成失败', phase: 'error' }
|
||||
notify()
|
||||
generatingSource = null
|
||||
} else if (t === 'done') {
|
||||
if (generatingSource !== 'sse') return
|
||||
// 定时场景 done 带 archived=true: 后端已归档, 前端只切 done 态, 不调归档接口。
|
||||
state = { ...state, phase: 'done' }
|
||||
notify()
|
||||
generatingSource = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,272 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, type PriceLevel, type LevelType } from './api'
|
||||
|
||||
/**
|
||||
* AI 个股分析 —— 全局任务/报告 store(与 aiReportStore 解耦、并行存在)。
|
||||
*
|
||||
* 与财务分析 store 的区别:
|
||||
* - 独立的 activeTasks / history 状态池(不共享 3 并发上限)
|
||||
* - meta 额外带 levels(关键价位),供图表回放
|
||||
* - 状态文案在胶囊组件里用「蓝」色系区分(财务用紫)
|
||||
*
|
||||
* 设计同 aiReportStore:流式接收逻辑在此,与弹窗解耦 → 关闭弹窗后台照常累积。
|
||||
*/
|
||||
|
||||
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
export interface ActiveTask {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
phase: Phase
|
||||
content: string
|
||||
error: string
|
||||
meta: {
|
||||
summary?: string
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
close?: number | null
|
||||
} | null
|
||||
createdAt: number
|
||||
savedReportId?: string
|
||||
doneAt?: number
|
||||
dismissed?: boolean
|
||||
}
|
||||
|
||||
export interface HistoryReport {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
content: string
|
||||
summary?: string
|
||||
close?: number | null
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const MAX_ACTIVE = 3
|
||||
|
||||
let activeTasks: ActiveTask[] = []
|
||||
let history: HistoryReport[] = []
|
||||
let historyLoaded = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
let activeDialogTaskId: string | null = null
|
||||
let dialogMinimized = false
|
||||
|
||||
function emit() { listeners.forEach(fn => fn()) }
|
||||
function subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn) } }
|
||||
|
||||
function normalizeAiError(msg: string) {
|
||||
return msg.includes('API Key') || msg.includes('api_key')
|
||||
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
|
||||
: msg
|
||||
}
|
||||
|
||||
let _activeSnap: ActiveTask[] = []
|
||||
let _historySnap: HistoryReport[] = []
|
||||
interface DialogSnap { taskId: string | null; minimized: boolean }
|
||||
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
|
||||
function rebuildSnap() {
|
||||
_activeSnap = activeTasks
|
||||
_historySnap = history
|
||||
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
}
|
||||
|
||||
function getActiveSnapshot() { return _activeSnap }
|
||||
function getHistorySnapshot() { return _historySnap }
|
||||
function getDialogSnapshot() { return _dialogSnap }
|
||||
|
||||
function patchTask(id: string, patch: Partial<ActiveTask>) {
|
||||
activeTasks = activeTasks.map(t => {
|
||||
if (t.id !== id) return t
|
||||
const next = { ...t, ...patch }
|
||||
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
|
||||
next.doneAt = Date.now()
|
||||
}
|
||||
return next
|
||||
})
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
// ===== 查询 hooks =====
|
||||
|
||||
export function useBubbleTasks(): ActiveTask[] {
|
||||
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
const ds = _dialogSnap
|
||||
return all.filter(t => {
|
||||
if (t.phase === 'loading' || t.phase === 'streaming') {
|
||||
return !(ds.taskId === t.id && !ds.minimized)
|
||||
}
|
||||
if (t.dismissed) return false
|
||||
if (!ds.minimized && ds.taskId === t.id) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
|
||||
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
return { reports, loaded: historyLoaded }
|
||||
}
|
||||
|
||||
export function useDialogState() {
|
||||
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
}
|
||||
|
||||
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
|
||||
const ds = useDialogState()
|
||||
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
if (!ds.taskId) return { task: null, mode: null }
|
||||
if (ds.taskId.startsWith('history:')) {
|
||||
const rid = ds.taskId.slice('history:'.length)
|
||||
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
|
||||
}
|
||||
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
|
||||
}
|
||||
|
||||
// ===== 动作 =====
|
||||
|
||||
export async function loadHistory(): Promise<void> {
|
||||
try {
|
||||
const res = await api.stockAnalysisReportsList()
|
||||
history = res.reports ?? []
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
|
||||
if (!historyLoaded) await loadHistory()
|
||||
return history.find(r => r.symbol === symbol) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某只股票【当日】是否已生成过分析报告(用于二次确认)。
|
||||
* 判断依据:created_at 的日期部分 == 本地今天。
|
||||
* @returns 当天最近一条报告,或 null
|
||||
*/
|
||||
export async function findTodayReport(symbol: string): Promise<HistoryReport | null> {
|
||||
if (!historyLoaded) await loadHistory()
|
||||
const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
|
||||
return history.find(r => r.symbol === symbol && (r.created_at ?? '').slice(0, 10) === today) ?? null
|
||||
}
|
||||
|
||||
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
|
||||
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
|
||||
if (existing) {
|
||||
activeDialogTaskId = existing.id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
return { id: existing.id }
|
||||
}
|
||||
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
|
||||
if (ongoing.length >= MAX_ACTIVE) {
|
||||
return { error: `同时进行的个股分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
|
||||
}
|
||||
|
||||
const id = `stask_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const task: ActiveTask = {
|
||||
id, symbol, name, focus,
|
||||
phase: 'loading', content: '', error: '',
|
||||
meta: null, createdAt: Date.now(),
|
||||
}
|
||||
activeTasks = [...activeTasks, task]
|
||||
activeDialogTaskId = id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
|
||||
runStream(id, symbol, name, focus)
|
||||
return { id }
|
||||
}
|
||||
|
||||
async function runStream(id: string, symbol: string, _name: string, focus: string) {
|
||||
try {
|
||||
let firstDelta = true
|
||||
for await (const chunk of api.stockAnalyzeStream(symbol, focus)) {
|
||||
const cur = activeTasks.find(t => t.id === id)
|
||||
if (!cur) return
|
||||
switch (chunk.type) {
|
||||
case 'meta':
|
||||
patchTask(id, { meta: { summary: chunk.summary, levels: chunk.levels, close: chunk.close } })
|
||||
break
|
||||
case 'delta':
|
||||
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
|
||||
patchTask(id, { content: cur.content + (chunk.content ?? '') })
|
||||
break
|
||||
case 'error':
|
||||
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
|
||||
return
|
||||
case 'done':
|
||||
patchTask(id, { phase: 'done' })
|
||||
break
|
||||
}
|
||||
}
|
||||
const final = activeTasks.find(t => t.id === id)
|
||||
if (final && final.phase !== 'error') {
|
||||
// 兜底:流正常结束但从未收到 delta(后端在生成内容前异常断流)→ 标记失败,避免卡死
|
||||
if (!final.content) {
|
||||
patchTask(id, { phase: 'error', error: '分析未返回内容(后端可能异常中断),请重试' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.stockAnalysisReportSave({
|
||||
symbol: final.symbol, name: final.name, focus: final.focus,
|
||||
content: final.content, summary: final.meta?.summary ?? '',
|
||||
close: final.meta?.close ?? null, levels: final.meta?.levels,
|
||||
})
|
||||
if (res.report) {
|
||||
patchTask(id, { savedReportId: res.report.id })
|
||||
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
} catch { /* 持久化失败不影响展示 */ }
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message ?? '分析失败')
|
||||
patchTask(id, {
|
||||
phase: 'error',
|
||||
error: normalizeAiError(msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function openDialog(taskId: string) {
|
||||
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
export function minimizeDialog() {
|
||||
dialogMinimized = true; rebuildSnap(); emit()
|
||||
}
|
||||
export function closeDialog() {
|
||||
activeDialogTaskId = null; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
export function restoreDialog(taskId: string) {
|
||||
const t = activeTasks.find(x => x.id === taskId)
|
||||
if (t && (t.phase === 'done' || t.phase === 'error')) {
|
||||
patchTask(taskId, { dismissed: true })
|
||||
}
|
||||
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
|
||||
return startAnalysis(task.symbol, task.name, task.focus)
|
||||
}
|
||||
export async function deleteReport(reportId: string): Promise<void> {
|
||||
try {
|
||||
await api.stockAnalysisReportDelete(reportId)
|
||||
history = history.filter(r => r.id !== reportId)
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
export function openHistoryReport(reportId: string) {
|
||||
activeDialogTaskId = `history:${reportId}`; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 集中管理所有 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 = {
|
||||
/** 查询轮询 / 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
|
||||
slippage: 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'),
|
||||
|
||||
/** 数据页画像卡片显隐 (卡片key → 是否显示) */
|
||||
dataCardVisible: kv<Record<string, boolean>>('data-card-visible'),
|
||||
/** 数据页画像卡片顺序 (卡片key 数组, 长度=卡片总数) */
|
||||
dataCardOrder: kv<string[]>('data-card-order'),
|
||||
} 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,52 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 记忆"上次查看的个股"(按页面维度,localStorage 持久化)。
|
||||
*
|
||||
* 两个分析页(财务 / 个股)各自独立记忆,key 区分:
|
||||
* - financials: 最后查看的财务分析个股
|
||||
* - stock-analysis: 最后查看的个股分析个股
|
||||
*
|
||||
* 用法:
|
||||
* const { last, remember } = useLastStock('stock-analysis')
|
||||
* remember('000001.SZ', '平安银行') // 选中股票时调用
|
||||
* <LastStockChip stock={last} ... /> // 渲染在 PageHeader 右侧
|
||||
*/
|
||||
|
||||
export interface StockRef { symbol: string; name: string }
|
||||
|
||||
const PREFIX = 'last_stock:'
|
||||
|
||||
export function useLastStock(scope: string) {
|
||||
const [last, setLast] = useState<StockRef | null>(() => load(scope))
|
||||
|
||||
const remember = useCallback((symbol: string, name: string) => {
|
||||
const ref = { symbol, name }
|
||||
setLast(ref)
|
||||
save(scope, ref)
|
||||
}, [scope])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setLast(null)
|
||||
save(scope, null)
|
||||
}, [scope])
|
||||
|
||||
return { last, remember, clear }
|
||||
}
|
||||
|
||||
function load(scope: string): StockRef | null {
|
||||
try {
|
||||
const v = localStorage.getItem(PREFIX + scope)
|
||||
if (!v) return null
|
||||
const p = JSON.parse(v)
|
||||
if (p && typeof p.symbol === 'string' && typeof p.name === 'string') return p
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function save(scope: string, ref: StockRef | null) {
|
||||
try {
|
||||
if (ref) localStorage.setItem(PREFIX + scope, JSON.stringify(ref))
|
||||
else localStorage.removeItem(PREFIX + scope)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -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,147 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
|
||||
import { getQueryConfig } from './useQueryConfig'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { pushAlertToasts } from '@/components/AlertToast'
|
||||
import { feedReviewEvent } from './reviewStore'
|
||||
import type { StrategyAlertEvent } from './api'
|
||||
|
||||
/**
|
||||
* 全局 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 {
|
||||
// 忽略解析错误
|
||||
}
|
||||
})
|
||||
|
||||
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
|
||||
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
|
||||
es.addEventListener('review_progress', (e: MessageEvent) => {
|
||||
try {
|
||||
const evt = JSON.parse(e.data)
|
||||
feedReviewEvent(evt)
|
||||
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
|
||||
if (evt.type === 'done') {
|
||||
qc.invalidateQueries({ queryKey: QK.reviewReports })
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
})
|
||||
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
esRef.current = null
|
||||
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,14 @@
|
||||
/**
|
||||
* 订阅 reviewStore 的 React hook。
|
||||
*
|
||||
* mount 时订阅,store 变化触发 re-render;unmount 时退订(但 store 流继续跑)。
|
||||
* 用 useSyncExternalStore 保证与 React 18 并发模式兼容。
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import {
|
||||
getReviewState, subscribeReview, type ReviewState,
|
||||
} from '@/lib/reviewStore'
|
||||
|
||||
export function useReviewState(): ReviewState {
|
||||
return useSyncExternalStore(subscribeReview, getReviewState, getReviewState)
|
||||
}
|
||||
@@ -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,81 @@
|
||||
/**
|
||||
* 共享 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 自动刷新。
|
||||
|
||||
* poll=true 时启用条件轮询兜底: 仅在非交易时段每 60s 轮询一次,
|
||||
* 用于在交易时段边界 (11:30午休 / 12:55开盘 / 15:05收盘) 同步 is_trading_hours。
|
||||
* 交易时段不轮询 (SSE 已驱动刷新), 非交易时段无 SSE 推送, 需要兜底。
|
||||
* 只应在全局唯一挂载处 (Layout) 传 poll=true, 避免多页面重复轮询;
|
||||
* 其他调用方共享同一 queryKey 缓存, 无需自行轮询。
|
||||
*/
|
||||
export function useQuoteStatus(opts?: { enabled?: boolean; poll?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: QK.quoteStatus,
|
||||
queryFn: api.quoteStatus,
|
||||
enabled: opts?.enabled ?? true,
|
||||
refetchInterval: opts?.poll
|
||||
? (query) => (query.state.data?.is_trading_hours ? false : 60_000)
|
||||
: false,
|
||||
})
|
||||
}
|
||||
|
||||
/** 行情间隔 — 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,51 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
|
||||
import { router } from './router'
|
||||
import './index.css'
|
||||
|
||||
// 全局认证拦截: 任何 query/mutation 收到 401 (未登录/会话过期) → 跳登录页。
|
||||
// api.ts 的 request() 已对 401 静默 (不弹 toast), 这里统一负责跳转。
|
||||
// 排除 /login 自身的请求, 避免登录页请求失败又跳登录形成死循环。
|
||||
const _redirectToLogin = (() => {
|
||||
let redirecting = false
|
||||
return (err: unknown) => {
|
||||
if (redirecting) return
|
||||
if (!(err instanceof Error)) return
|
||||
const msg = err.message || ''
|
||||
// 401 (未登录/会话过期) → 跳登录页
|
||||
// 403 未初始化 (面板未设密码, 公网访问) → 也跳登录页(显示设密码提示)
|
||||
const is401 = msg.includes('未登录') || msg.includes('会话已过期') || msg.includes('401')
|
||||
const isNotInit = msg.includes('尚未初始化访问密码') || msg.includes('NOT_INITIALIZED')
|
||||
if (!is401 && !isNotInit) return
|
||||
// 已在登录页则不跳(避免死循环)
|
||||
if (window.location.pathname === '/login') return
|
||||
redirecting = true
|
||||
const redirect = encodeURIComponent(window.location.pathname + window.location.search)
|
||||
window.location.href = `/login?redirect=${redirect}`
|
||||
}
|
||||
})()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (err) => _redirectToLogin(err),
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5_000, // 5s 内复用,与 §4.2 Repository 不变量一致
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
onError: (err) => _redirectToLogin(err),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user